Merge Batch 25 — project organization (CC12, CC7, CC10)

This commit is contained in:
2026-07-02 15:37:28 -04:00
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 - [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:** 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)* 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 ### 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 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 `{ 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.* 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()`); `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 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 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. as the leading argument and keep singleton access for config/binaries.
*Deferred (low-value restyle): the substantive half already holds — the pure core *Fixed (Batch 25): the injected `wc`/`onProgress` is now the LEADING argument on every shell function that
(`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`) takes deps as params and the shell uses singletons takes one — `downloadAppUpdate(wc, url)`, `indexSource(onProgress, url, signal)`,
for config/binaries, which is the recommended split. What's left is purely cosmetic argument-ordering `indexSourceCancelable(onProgress, url)` — matching the existing `startDownload(wc, opts)` /
(`wc`/`onProgress` position), not worth the churn/risk across every handler unattended; recorded as the `runTerminal(wc, id, args)` / download.ts `send(wc, ev)`/`notify(wc, …)`. Call sites updated; the updater
convention for new handlers.* 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 - [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 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. 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 [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 row guards deliberately stay type-guard predicates (hot read path filtering untrusted disk rows — no
coercion wanted), documented in the schema header.* 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 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. 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 **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. **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 *Resolved as deliberate-by-design (Batch 25): the `jsonStore<T>()` abstraction (`createJsonStore`, M4/R1)
every app record (history/errorlog/templates/sources/media-items/queue), so the "hand-rolled per file" already backs every app record (history/errorlog/templates/sources/media-items/queue), so the
divergence is gone. The remaining electron-store↔jsonStore split is a **deliberate** keep: settings live in "hand-rolled per file" divergence is gone. The remaining electron-store↔jsonStore split is a deliberate
electron-store for its DPAPI-encrypted secret fields, records in jsonStore. Fully migrating settings off KEEP, not drift: settings live in electron-store for its DPAPI-encrypted secret fields (proxy/PO-token/
electron-store is a 1.x call, not forced here.* 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 - [x] **CC11 — Configuration is scattered.** `electron-store` + `localStorage` (sidebar, M19) + env vars
(`PORTABLE_EXECUTABLE_DIR`, `CSC_LINK`, `AEROFETCH_REAL_DOWNLOAD`) + hardcoded module consts (update (`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 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, 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 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.* 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/`; 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. 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`. **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 — *Fixed (Batch 25): the reorg landed. Renderer — the five screens + Onboarding + `useTerminalRun` +
exactly the kind of sweeping move that wants its own reviewed PR, not an unattended batch. The `lib/` the `settings/` cards moved to `views/`; the root helpers (`theme`/`thumb`/`thumbSizes`/`datetime`/`id`/
convention is already established (both sides have one and new pure utils land there); the wholesale `qualityOptions`/`useClipboardLink`) and the pure `queueStats` moved to `lib/` (which already held
`views/`+`core/` split is the 1.x task. Standard recorded.* `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/ - [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 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/ 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 { getSettings, setSettings, SECRET_KEYS } from './settings'
import { migrateSettingsKeys } from './settingsMigration' import { migrateSettingsKeys } from './settingsMigration'
import { listTemplates, replaceTemplates } from './templates' import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation' import { isTemplateLike } from './core/validation'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc' import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile { interface BackupFile {
+2 -2
View File
@@ -23,7 +23,7 @@ import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies' import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates' import { listTemplates } from './templates'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation' import { isSafeOutputDir } from './core/validation'
import { import {
buildArgs, buildArgs,
selectExtraArgs, selectExtraArgs,
@@ -32,7 +32,7 @@ import {
PROGRESS_MARKER, PROGRESS_MARKER,
FILEPATH_MARKER, FILEPATH_MARKER,
DEST_MARKER DEST_MARKER
} from './buildArgs' } from './core/buildArgs'
import { cleanError } from './log' import { cleanError } from './log'
import { addErrorLog } from './errorlog' import { addErrorLog } from './errorlog'
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ErrorLogEntry } from '@shared/ipc' import type { ErrorLogEntry } from '@shared/ipc'
import { isValidErrorLogEntry } from './validation' import { isValidErrorLogEntry } from './core/validation'
import { createJsonStore } from './jsonStore' import { createJsonStore } from './jsonStore'
import { ERRORLOG_MAX_ENTRIES } from './constants' import { ERRORLOG_MAX_ENTRIES } from './constants'
+1 -1
View File
@@ -1,5 +1,5 @@
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc' import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation' import { isValidHistoryEntry } from './core/validation'
import { createJsonStore } from './jsonStore' import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe). // Plain JSON in userData (portable build redirects userData next to the exe).
+5 -5
View File
@@ -24,7 +24,7 @@ import {
stableSourceId, stableSourceId,
type RawEntry, type RawEntry,
type NamedPlaylist type NamedPlaylist
} from './indexerCore' } from './core/indexerCore'
import { getSource, upsertSource, mergeMediaItems } from './sources' import { getSource, upsertSource, mergeMediaItems } from './sources'
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc' 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). * The IPC layer calls this; sync.ts calls the raw indexSource (not cancelable).
*/ */
export async function indexSourceCancelable( export async function indexSourceCancelable(
url: string, onProgress: (p: IndexProgress) => void,
onProgress: (p: IndexProgress) => void url: string
): Promise<IndexSourceResult> { ): Promise<IndexSourceResult> {
const controller = new AbortController() const controller = new AbortController()
activeIndexAborts.add(controller) activeIndexAborts.add(controller)
try { try {
return await indexSource(url, onProgress, controller.signal) return await indexSource(onProgress, url, controller.signal)
} finally { } finally {
activeIndexAborts.delete(controller) activeIndexAborts.delete(controller)
} }
@@ -107,8 +107,8 @@ export async function indexSourceCancelable(
* status line — it's best-effort and never affects the result. * status line — it's best-effort and never affects the result.
*/ */
export async function indexSource( export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void, onProgress: (p: IndexProgress) => void,
url: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<IndexSourceResult> { ): Promise<IndexSourceResult> {
// Returned whenever a Cancel arrives mid-walk. It deliberately does NOT push a // 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.appUpdateCheck, () => checkForAppUpdate())
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) => ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender) downloadAppUpdate(e.sender, url)
) )
ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate()) ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate())
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath)) 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` // Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result. // and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) => ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSourceCancelable(url, (p) => { indexSourceCancelable((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
}) }, url)
) )
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => { ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id) const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' } 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) 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) // Abort the in-flight index/reindex walk (kills the current probe child). (B2)
ipcMain.handle(IpcChannels.sourceIndexCancel, () => cancelIndexing()) 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 { fmtBytes } from '@shared/format'
import { cleanError } from './log' import { cleanError } from './log'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore' import { entryUrl, fmtDuration, type RawEntry } from './core/indexerCore'
import { import {
BEST_FORMAT_ID, BEST_FORMAT_ID,
type ProbeResult, type ProbeResult,
+1 -1
View File
@@ -25,7 +25,7 @@ import {
isValidSyncTime, isValidSyncTime,
type Settings type Settings
} from '@shared/ipc' } from '@shared/ipc'
import { isSafeOutputDir, isSafeFilenameTemplate } from './validation' import { isSafeOutputDir, isSafeFilenameTemplate } from './core/validation'
import { sanitizeOptions } from './settingsOptions' import { sanitizeOptions } from './settingsOptions'
/** Coerce to a finite number, round, and clamp into [min, max] (maxConcurrent, aria2c). */ /** 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 type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation' import { isValidSource, isValidMediaItem } from './core/validation'
import { mergeItemsPreservingState } from './indexerCore' import { mergeItemsPreservingState } from './core/indexerCore'
import { createJsonStore } from './jsonStore' import { createJsonStore } from './jsonStore'
// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) so a runaway index // 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 // 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 { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer' import { indexSource } from './indexer'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore' import { parseRssVideoIds, isYouTubeFeedUrl } from './core/indexerCore'
import { FEED_FETCH_TIMEOUT_MS } from './constants' import { FEED_FETCH_TIMEOUT_MS } from './constants'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc' 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 if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
} }
const before = new Set(listMediaItems(src.id).map((m) => m.videoId)) 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) { if (res.ok) {
for (const it of listMediaItems(src.id)) { for (const it of listMediaItems(src.id)) {
if (!before.has(it.videoId)) newItems.push(it) if (!before.has(it.videoId)) newItems.push(it)
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation' import { isTemplateLike } from './core/validation'
import { createJsonStore } from './jsonStore' import { createJsonStore } from './jsonStore'
import { TEMPLATES_MAX } from './constants' import { TEMPLATES_MAX } from './constants'
+1 -1
View File
@@ -14,7 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings' import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp' import { ensureManagedYtdlp } from './ytdlp'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock' import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { parseExtraArgs } from './buildArgs' import { parseExtraArgs } from './core/buildArgs'
import { createLineBuffer } from './lib/lineBuffer' import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc' 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. */ /** 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)) { if (!isTrustedDownloadUrl(url)) {
return { ok: false, error: 'Refused to download from an untrusted location.' } 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 { cleanError } from './log'
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants' import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
import { getSettings, setSettings } from './settings' import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy' import { shouldAutoCheckYtdlp } from './core/ytdlpPolicy'
import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock' import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
import { import {
isYtdlpUpdateChannel, isYtdlpUpdateChannel,
+8 -8
View File
@@ -1,12 +1,12 @@
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react' import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components' import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './views/DownloadsView'
import { Onboarding } from './components/Onboarding' import { Onboarding } from './views/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette' import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion' import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster' import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useNav } from './store/nav' import { useNav } from './store/nav'
import { useDownloads } from './store/downloads' 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 // 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). // owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources' import './store/sources'
import { queueSummaryOf } from './store/queueStats' import { queueSummaryOf } from './lib/queueStats'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError' import { logError } from './reportError'
@@ -26,16 +26,16 @@ import { logError } from './reportError'
// views use are imported eagerly above (`useDownloads` + `./store/sources`, and // views use are imported eagerly above (`useDownloads` + `./store/sources`, and
// downloads → history), so lazy-loading the *view* never delays store startup. // downloads → history), so lazy-loading the *view* never delays store startup.
const LibraryView = lazy(() => const LibraryView = lazy(() =>
import('./components/LibraryView').then((m) => ({ default: m.LibraryView })) import('./views/LibraryView').then((m) => ({ default: m.LibraryView }))
) )
const HistoryView = lazy(() => const HistoryView = lazy(() =>
import('./components/HistoryView').then((m) => ({ default: m.HistoryView })) import('./views/HistoryView').then((m) => ({ default: m.HistoryView }))
) )
const TerminalView = lazy(() => const TerminalView = lazy(() =>
import('./components/TerminalView').then((m) => ({ default: m.TerminalView })) import('./views/TerminalView').then((m) => ({ default: m.TerminalView }))
) )
const SettingsView = lazy(() => 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 // 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 { useEffect, useRef } from 'react'
import { type MediaKind } from '../store/downloads' import { type MediaKind } from '../store/downloads'
import { useNav } from '../store/nav' import { useNav } from '../store/nav'
import { QUALITY_OPTIONS } from '../qualityOptions' import { QUALITY_OPTIONS } from '../lib/qualityOptions'
import { Select } from './Select' import { Select } from './Select'
import { Hint } from './Hint' import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl' 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 { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import type { MediaKind } from '@shared/ipc' import type { MediaKind } from '@shared/ipc'
import { useResolvedDark } from '../store/systemTheme' import { useResolvedDark } from '../store/systemTheme'
import { thumbColors } from '../theme' import { thumbColors } from '../lib/theme'
const useStyles = makeStyles({ const useStyles = makeStyles({
box: { box: {
+3 -3
View File
@@ -27,8 +27,8 @@ import {
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../lib/thumb'
import { fmtSchedule } from '../datetime' import { fmtSchedule } from '../lib/datetime'
import { fmtSpeed, fmtEta } from '@shared/format' import { fmtSpeed, fmtEta } from '@shared/format'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint' import { Hint } from './Hint'
@@ -36,7 +36,7 @@ import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text' import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens' import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_MD } from '../thumbSizes' import { THUMB_MD } from '../lib/thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -19,7 +19,7 @@ import {
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc' import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates' import { useTemplates } from '../store/templates'
import { newId } from '../id' import { newId } from '../lib/id'
import { Hint } from './Hint' import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens' import { SPACE } from './ui/tokens'
@@ -1,5 +1,5 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components' 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' import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
export const useDownloadBarStyles = makeStyles({ export const useDownloadBarStyles = makeStyles({
@@ -10,8 +10,8 @@ import {
} from '@shared/ipc' } from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads' import { useDownloads, type MediaKind } from '../../store/downloads'
import { useHistory } from '../../store/history' import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions' import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { sameVideo } from '../../store/queueStats' import { sameVideo } from '../../lib/queueStats'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useNav } from '../../store/nav' import { useNav } from '../../store/nav'
import { import {
@@ -20,7 +20,7 @@ import {
looksLikeChannelOrPlaylist, looksLikeChannelOrPlaylist,
firstUrlInText, firstUrlInText,
type SuggestionSource type SuggestionSource
} from '../../useClipboardLink' } from '../../lib/useClipboardLink'
import { logError } from '../../reportError' import { logError } from '../../reportError'
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */ /** 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 * / window dependency (only a type-only import of DownloadItem) so it can be
* unit-tested without constructing the Zustand store or its preview ticker. * 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 { youtubeId } from '../lib/urlHelpers'
import { fmtSpeed, fmtEta } from '@shared/format' import { fmtSpeed, fmtEta } from '@shared/format'
@@ -4,7 +4,7 @@
* video id with no extra network probe. Anything non-YouTube without a probed * 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. * 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 * 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 { 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 // 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 // without pulling in the Zustand store (L40). Re-exported here for existing
// consumers (DownloadBar, LibraryView) without an import-path change. // consumers (DownloadBar, LibraryView) without an import-path change.
@@ -8,8 +8,8 @@ export {
looksLikeSingleVideo, looksLikeSingleVideo,
looksLikeChannelOrPlaylist, looksLikeChannelOrPlaylist,
firstUrlInText firstUrlInText
} from './lib/urlHelpers' } from './urlHelpers'
import { looksLikeUrl } from './lib/urlHelpers' import { looksLikeUrl } from './urlHelpers'
/** Where an offered link came from — drives the banner's wording. */ /** Where an offered link came from — drives the banner's wording. */
export type SuggestionSource = 'clipboard' | 'external' export type SuggestionSource = 'clipboard' | 'external'
+1 -1
View File
@@ -1,5 +1,5 @@
import { type DownloadMeta, type MediaKind } from '@shared/ipc' import { type DownloadMeta, type MediaKind } from '@shared/ipc'
import { newId } from '../id' import { newId } from '../lib/id'
import { youtubeId } from '../lib/urlHelpers' import { youtubeId } from '../lib/urlHelpers'
import { type AddOptions, type DownloadItem } from './downloadTypes' import { type AddOptions, type DownloadItem } from './downloadTypes'
+1 -1
View File
@@ -1,5 +1,5 @@
import { isPreview as PREVIEW } from '../isPreview' import { isPreview as PREVIEW } from '../isPreview'
import { newId } from '../id' import { newId } from '../lib/id'
import { type DownloadItem } from './downloadTypes' import { type DownloadItem } from './downloadTypes'
// Mock seed data so every visual state is visible in the UI preview. Empty in a // 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 { 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 * A tiny transient-notification store (audit UX6/UX9). The app had no way to tell
@@ -10,13 +10,13 @@
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem } from '../store/downloads' import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats' import { queueSummaryOf } from '../lib/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from '../components/DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from '../components/QueueItem'
import { VirtualList } from './VirtualList' import { VirtualList } from '../components/VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { SPACE, ICON, META_SEP } from './ui/tokens' import { SPACE, ICON, META_SEP } from '../components/ui/tokens'
// Hoisted so the virtualizer gets stable function identities and doesn't churn its // Hoisted so the virtualizer gets stable function identities and doesn't churn its
// measurement/key cache on every render (PERF5). // measurement/key cache on every render (PERF5).
@@ -22,21 +22,21 @@ import {
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc' import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history' import { useHistory } from '../store/history'
import { formatWhen } from '../datetime' import { formatWhen } from '../lib/datetime'
import { useResolvedDark } from '../store/systemTheme' import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads' import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme' import { thumbColors } from '../lib/theme'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../lib/thumb'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from '../components/MediaThumb'
import { Hint } from './Hint' import { Hint } from '../components/Hint'
import { Select } from './Select' import { Select } from '../components/Select'
import { VirtualList } from './VirtualList' import { VirtualList } from '../components/VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from './ui/text' import { useTextStyles } from '../components/ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens' import { SPACE, ICON, META_SEP } from '../components/ui/tokens'
import { THUMB_SM } from '../thumbSizes' import { THUMB_SM } from '../lib/thumbSizes'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -29,22 +29,22 @@ import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { useSources } from '../store/sources' import { useSources } from '../store/sources'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { useNav } from '../store/nav' import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink' import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads' import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb' import { thumbUrl } from '../lib/thumb'
import { relTime } from '../datetime' import { relTime } from '../lib/datetime'
import { MediaThumb } from './MediaThumb' import { MediaThumb } from '../components/MediaThumb'
import { VirtualList } from './VirtualList' import { VirtualList } from '../components/VirtualList'
import { Hint } from './Hint' import { Hint } from '../components/Hint'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { StatusChip } from './ui/StatusChip' import { StatusChip } from '../components/ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl' import { SegmentedControl } from '../components/ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { LinkSuggestion } from './ui/LinkSuggestion' import { LinkSuggestion } from '../components/ui/LinkSuggestion'
import { useFocusStyles } from './ui/focusRing' import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from './ui/text' import { useTextStyles } from '../components/ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens' import { SPACE, RADIUS, ICON, META_SEP } from '../components/ui/tokens'
import { THUMB_XS } from '../thumbSizes' import { THUMB_XS } from '../lib/thumbSizes'
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */ /** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending' type ItemStatus = DownloadStatus | 'pending'
@@ -22,7 +22,7 @@ import {
KeyboardRegular KeyboardRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens' import { SPACE, ICON } from '../components/ui/tokens'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -12,8 +12,8 @@ import {
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { SearchRegular } from '@fluentui/react-icons' import { SearchRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { SPACE } from './ui/tokens' import { SPACE } from '../components/ui/tokens'
import { DownloadsCard } from './settings/DownloadsCard' import { DownloadsCard } from './settings/DownloadsCard'
import { AppearanceCard } from './settings/AppearanceCard' import { AppearanceCard } from './settings/AppearanceCard'
import { PostProcessingCard } from './settings/PostProcessingCard' import { PostProcessingCard } from './settings/PostProcessingCard'
@@ -11,9 +11,9 @@ import {
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons' import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { ScreenHeader, useScreenStyles } from './ui/Screen' import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from './ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { SPACE } from './ui/tokens' import { SPACE } from '../components/ui/tokens'
import { useTerminalRun, type LineKind } from './useTerminalRun' import { useTerminalRun, type LineKind } from './useTerminalRun'
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -13,8 +13,8 @@ import {
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons' import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import { type YtdlpUpdateChannel } from '@shared/ipc' import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { Select } from '../Select' import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useAboutCard } from './useAboutCard' import { useAboutCard } from './useAboutCard'
@@ -12,9 +12,9 @@ import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
import { type ThemeMode, type AccentColor } from '@shared/ipc' import { type ThemeMode, type AccentColor } from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useSystemTheme } from '../../store/systemTheme' import { useSystemTheme } from '../../store/systemTheme'
import { Select } from '../Select' import { Select } from '../../components/Select'
import { ACCENT_OPTIONS } from '../../theme' import { ACCENT_OPTIONS } from '../../lib/theme'
import { useFocusStyles } from '../ui/focusRing' import { useFocusStyles } from '../../components/ui/focusRing'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
const THEME_MODE_OPTIONS = [ const THEME_MODE_OPTIONS = [
@@ -1,6 +1,6 @@
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components' import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons' import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useBackupCard } from './useBackupCard' 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 { CookiesRegular } from '@fluentui/react-icons'
import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc' import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { Select } from '../Select' import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useCookiesCard } from './useCookiesCard' 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 { CodeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates' import { useTemplates } from '../../store/templates'
import { Select } from '../Select' import { Select } from '../../components/Select'
import { TemplateManager } from '../TemplateManager' import { TemplateManager } from '../../components/TemplateManager'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
export function CustomCommandsCard(): React.JSX.Element { 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 { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons' import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog' import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { EmptyState } from '../ui/EmptyState' import { EmptyState } from '../../components/ui/EmptyState'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
export function DiagnosticsCard(): React.JSX.Element { export function DiagnosticsCard(): React.JSX.Element {
@@ -11,9 +11,9 @@ import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
import { type MediaKind } from '@shared/ipc' import { type MediaKind } from '@shared/ipc'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useDownloads } from '../../store/downloads' import { useDownloads } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions' import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { Select } from '../Select' import { Select } from '../../components/Select'
import { SegmentedControl } from '../ui/SegmentedControl' import { SegmentedControl } from '../../components/ui/SegmentedControl'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
export function DownloadsCard(): React.JSX.Element { export function DownloadsCard(): React.JSX.Element {
@@ -1,7 +1,7 @@
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components' import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { OptionsRegular } from '@fluentui/react-icons' import { OptionsRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { DownloadOptionsForm } from '../DownloadOptionsForm' import { DownloadOptionsForm } from '../../components/DownloadOptionsForm'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
export function PostProcessingCard(): React.JSX.Element { export function PostProcessingCard(): React.JSX.Element {
@@ -17,7 +17,7 @@ import {
DismissRegular DismissRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings' import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText' import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles' import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard' import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
@@ -1,5 +1,5 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components' 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 // 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 // stays one direct child of SettingsView's root -- the search filter toggles
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { newId } from '../id' import { newId } from '../lib/id'
export type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' export type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
export interface Line { export interface Line {
+1 -1
View File
@@ -12,7 +12,7 @@ import {
aria2cArgs, aria2cArgs,
DEST_MARKER, DEST_MARKER,
type AccessOptions type AccessOptions
} from '../src/main/buildArgs' } from '../src/main/core/buildArgs'
import { import {
DEFAULT_DOWNLOAD_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions, type DownloadOptions,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest' 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(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { newId } from '../src/renderer/src/id' import { newId } from '../src/renderer/src/lib/id'
describe('newId', () => { describe('newId', () => {
it('produces unique ids across many calls', () => { it('produces unique ids across many calls', () => {
+1 -1
View File
@@ -11,7 +11,7 @@ import {
stripTabSuffix, stripTabSuffix,
stableSourceId, stableSourceId,
type RawEntry type RawEntry
} from '../src/main/indexerCore' } from '../src/main/core/indexerCore'
import type { MediaItem } from '@shared/ipc' import type { MediaItem } from '@shared/ipc'
describe('classifySource', () => { describe('classifySource', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' 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' import type { DownloadItem } from '../src/renderer/src/store/downloads'
// Minimal item factory — only the fields summarizeQueue reads. // 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 { mkdtempSync, existsSync, rmSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join, resolve } from 'path' 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' import { DEFAULT_DOWNLOAD_OPTIONS, type StartDownloadOptions } from '@shared/ipc'
const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1' const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
+14 -14
View File
@@ -138,7 +138,7 @@ afterAll(() => {
describe('downloadAppUpdate (CL4 behavior pins)', () => { describe('downloadAppUpdate (CL4 behavior pins)', () => {
it('refuses an untrusted initial URL without making any request', async () => { it('refuses an untrusted initial URL without making any request', async () => {
const { wc } = fakeWc() 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.ok).toBe(false)
expect(r.error).toMatch(/untrusted/i) expect(r.error).toMatch(/untrusted/i)
expect(madeRequests.length).toBe(0) expect(madeRequests.length).toBe(0)
@@ -154,7 +154,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc, sends } = fakeWc() const { wc, sends } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r).toEqual({ ok: true, filePath: installerPath }) expect(r).toEqual({ ok: true, filePath: installerPath })
expect(readFileSync(installerPath).equals(INSTALLER)).toBe(true) expect(readFileSync(installerPath).equals(INSTALLER)).toBe(true)
expect(sends.length).toBeGreaterThan(0) expect(sends.length).toBeGreaterThan(0)
@@ -171,7 +171,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/checksum/i) expect(r.error).toMatch(/checksum/i)
await expectGone(installerPath) await expectGone(installerPath)
@@ -186,7 +186,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/incomplete/i) expect(r.error).toMatch(/incomplete/i)
await expectGone(installerPath) await expectGone(installerPath)
@@ -198,7 +198,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('redirect', 302, 'GET', 'https://evil.example/attachments/x.exe') req.emit('redirect', 302, 'GET', 'https://evil.example/attachments/x.exe')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/redirect.*untrusted/i) expect(r.error).toMatch(/redirect.*untrusted/i)
const installerReq = madeRequests[1]! const installerReq = madeRequests[1]!
@@ -216,7 +216,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true) expect(r.ok).toBe(true)
expect(madeRequests[1]!.redirectsFollowed).toBe(1) 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') req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/checksum/i) expect(r.error).toMatch(/checksum/i)
expect(madeRequests.length).toBe(1) // never reached the installer request 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)) req.emit('response', new FakeResponse(404))
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/no checksum/i) expect(r.error).toMatch(/no checksum/i)
expect(madeRequests.length).toBe(1) 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 () => { it('refuses a malformed checksum file (no 64-hex digest)', async () => {
requestScripts.push(checksumOk('not a digest at all\n')) requestScripts.push(checksumOk('not a digest at all\n'))
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/malformed/i) expect(r.error).toMatch(/malformed/i)
expect(madeRequests.length).toBe(1) expect(madeRequests.length).toBe(1)
@@ -262,7 +262,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/canceled/i) expect(r.error).toMatch(/canceled/i)
expect(madeRequests.length).toBe(1) expect(madeRequests.length).toBe(1)
@@ -277,7 +277,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
cancelAppUpdate() cancelAppUpdate()
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/canceled/i) expect(r.error).toMatch(/canceled/i)
expect(madeRequests[1]!.aborted).toBe(true) expect(madeRequests[1]!.aborted).toBe(true)
@@ -294,7 +294,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
// …and then nothing: the stream stalls. // …and then nothing: the stream stalls.
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const pending = downloadAppUpdate(ASSET, wc) const pending = downloadAppUpdate(wc, ASSET)
await vi.advanceTimersByTimeAsync(61_000) await vi.advanceTimersByTimeAsync(61_000)
const r = await pending const r = await pending
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
@@ -310,7 +310,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('response', new FakeResponse(503)) req.emit('response', new FakeResponse(503))
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false) expect(r.ok).toBe(false)
expect(r.error).toMatch(/503/) expect(r.error).toMatch(/503/)
}) })
@@ -327,7 +327,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end') res.emit('end')
}) })
const { wc } = fakeWc() const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc) const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true) expect(r.ok).toBe(true)
}) })
}) })
+1 -1
View File
@@ -7,7 +7,7 @@ import {
isTemplateLike, isTemplateLike,
isValidSource, isValidSource,
isValidMediaItem isValidMediaItem
} from '../src/main/validation' } from '../src/main/core/validation'
import { import {
isValidSyncTime, isValidSyncTime,
type HistoryEntry, type HistoryEntry,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' 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" const NOW = 1_782_435_469_486 // arbitrary fixed "now"