feat(audit): L51 + L64 — daily-sync time picker + aria2c connection tuning
Two small self-contained features from the audit's deferred list: - L51: the Task Scheduler daily sync is no longer pinned to 09:00. New Settings.syncTime (24h HH:MM) with a native time input beside the Library "Daily sync" switch — persists on change, re-registers the task on blur (schtasks /Create /F overwrite is the time-change mechanism). The value reaches the schtasks /ST argv, so a shared isValidSyncTime guards it at both the settings write and in schedule.ts (unit-tested incl. injection-shaped input). Live-verified against the machine's real task. - L64: aria2c connection count (-x/-s) is user-tunable. New Settings.aria2cConnections (1-16, aria2c's own ceiling) exposed as a SpinButton in Settings -> Network while the aria2c toggle is on. ARIA2C_ARGS became the pure aria2cArgs(n?) with a defensive clamp; threaded through AccessOptions. The -k 1M split floor stays fixed — a free-text field would bypass the custom-command consent gate. typecheck + 309 tests + eslint + production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+19
-6
@@ -797,9 +797,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
`\n`, which renders awkwardly in inline/Caption error spans.
|
`\n`, which renders awkwardly in inline/Caption error spans.
|
||||||
- [x] **L50 — "Cookies saved" shown for 0 cookies.** Closing the sign-in window without logging in
|
- [x] **L50 — "Cookies saved" shown for 0 cookies.** Closing the sign-in window without logging in
|
||||||
still writes the file and reports `ok` with `cookieCount: 0`.
|
still writes the file and reports `ok` with `cookieCount: 0`.
|
||||||
- [ ] **L51 — Scheduled daily sync time hardcoded to 09:00** (schedule.ts) — on/off toggle only, no time picker.
|
- [x] **L51 — Scheduled daily sync time hardcoded to 09:00** (schedule.ts) — on/off toggle only, no time picker.
|
||||||
*Deferred (feature): a time picker is a small new feature (UI + a Task Scheduler time argument), not a
|
*Fixed. New `Settings.syncTime` (24h 'HH:MM', default '09:00'); a compact native time input appears next to
|
||||||
cleanup — worth its own change; 09:00 is a reasonable default meanwhile.*
|
the Library's "Daily sync" switch when it's on. `setScheduledSync(enabled, time?)` threads the time into
|
||||||
|
`schtasks /ST`, guarded by a shared `isValidSyncTime` (in @shared/ipc, used by both main's settings
|
||||||
|
validation and schedule.ts — the value reaches the schtasks argv, so malformed input falls back to the
|
||||||
|
default rather than being forwarded; unit-tested incl. injection-shaped values). The picker persists via
|
||||||
|
settings on change and re-registers the task on blur (one `schtasks /Create /F` per commit, not per
|
||||||
|
keystroke — /F overwrite IS the time-change mechanism). Live-verified on this machine: re-registering the
|
||||||
|
real AeroFetchDailySync task with /ST 19:30 moved its start to 7:30 PM and back (the user's original 09:00
|
||||||
|
registration restored byte-exact afterwards).*
|
||||||
- [x] **L52 — Installer handoff is a fixed `setTimeout(app.quit, 1500)`** (updater.ts) — a race on slow machines.
|
- [x] **L52 — Installer handoff is a fixed `setTimeout(app.quit, 1500)`** (updater.ts) — a race on slow machines.
|
||||||
- [x] **L53 — `dialog.show*Dialog(win!, …)` non-null assertions** on a possibly-null window
|
- [x] **L53 — `dialog.show*Dialog(win!, …)` non-null assertions** on a possibly-null window
|
||||||
(chooseFolder, backup export/import) — can throw if the sender window is gone.
|
(chooseFolder, backup export/import) — can throw if the sender window is gone.
|
||||||
@@ -818,9 +825,15 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
|||||||
- [x] **L62 — "Best available" synthetic format hardcodes `ext:'mp4'`/`hasAudio:true`** (probe.ts) —
|
- [x] **L62 — "Best available" synthetic format hardcodes `ext:'mp4'`/`hasAudio:true`** (probe.ts) —
|
||||||
mislabels when the chosen container is mkv/webm.
|
mislabels when the chosen container is mkv/webm.
|
||||||
- [x] **L63 — `audioQuality()` unknown label → silent `'0'` (best)** — the audio analog of H5.
|
- [x] **L63 — `audioQuality()` unknown label → silent `'0'` (best)** — the audio analog of H5.
|
||||||
- [ ] **L64 — `ARIA2C_ARGS` connection params hardcoded** (`-x16 -s16 -k1M`), no user control.
|
- [x] **L64 — `ARIA2C_ARGS` connection params hardcoded** (`-x16 -s16 -k1M`), no user control.
|
||||||
*Deferred (feature): exposing aria2c tuning is a new advanced setting, not a cleanup; the defaults are
|
*Fixed. New `Settings.aria2cConnections` (1–16, default 16 — aria2c's own -x/-s ceiling), exposed as an
|
||||||
sensible and aria2c itself is already opt-in.*
|
"aria2c connections" SpinButton in Settings → Network, shown only while the aria2c toggle is on (same
|
||||||
|
pattern as maxConcurrent). The const became a pure `aria2cArgs(connections?)` in buildArgs.ts that clamps/
|
||||||
|
rounds defensively (settings validation clamps too — `applySettings` mirrors the maxConcurrent case);
|
||||||
|
`ARIA2C_ARGS` remains exported as `aria2cArgs()` so existing references stay valid. Threaded through
|
||||||
|
`AccessOptions.aria2cConnections` from buildCommand. The `-k 1M` split floor stays fixed — the meaningful
|
||||||
|
user knob is connection count; a free-text field would bypass the customCommandEnabled consent gate.
|
||||||
|
Unit-tested: custom count reaches `--downloader-args`, clamp floor/ceiling/rounding/non-finite.*
|
||||||
- [x] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop.
|
- [x] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop.
|
||||||
*Verified acceptable in the Batch 15 live pass — no code change needed. Live test (a real 360p download paused
|
*Verified acceptable in the Batch 15 live pass — no code change needed. Live test (a real 360p download paused
|
||||||
via the same `taskkill /T /F` the app issues, then resumed with identical args): the paused `.part` was left
|
via the same `taskkill /T /F` the app issues, then resumed with identical args): the paused `.part` was left
|
||||||
|
|||||||
+22
-4
@@ -104,6 +104,8 @@ export interface AccessOptions {
|
|||||||
rateLimit: string
|
rateLimit: string
|
||||||
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
|
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
|
||||||
aria2cPath?: string
|
aria2cPath?: string
|
||||||
|
/** aria2c parallel connections (-x/-s), clamped to 1–16; omitted = 16 (L64) */
|
||||||
|
aria2cConnections?: number
|
||||||
/** read auth cookies from an installed browser's own cookie store */
|
/** read auth cookies from an installed browser's own cookie store */
|
||||||
cookiesFromBrowser?: CookieBrowser
|
cookiesFromBrowser?: CookieBrowser
|
||||||
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
|
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
|
||||||
@@ -118,9 +120,23 @@ export interface AccessOptions {
|
|||||||
youtubePoToken?: string
|
youtubePoToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
|
/**
|
||||||
// each at least 1MB so tiny files don't get split pointlessly.
|
* The yt-dlp `--downloader-args` value for aria2c: N connections split across N
|
||||||
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
|
* segments, each at least 1MB so tiny files don't get split pointlessly. The
|
||||||
|
* connection count is user-tunable (Settings → Network, L64) and clamped here to
|
||||||
|
* aria2c's own -x/-s ceiling of 16 — settings validation clamps too, but this is
|
||||||
|
* pure-module defence for any other caller. Non-finite input → the default 16.
|
||||||
|
*/
|
||||||
|
export function aria2cArgs(connections?: number): string {
|
||||||
|
const n =
|
||||||
|
connections !== undefined && Number.isFinite(connections)
|
||||||
|
? Math.min(16, Math.max(1, Math.round(connections)))
|
||||||
|
: 16
|
||||||
|
return `aria2c:-x ${n} -s ${n} -k 1M`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default tuning (16 connections) — kept for tests/docs that reference it. */
|
||||||
|
export const ARIA2C_ARGS = aria2cArgs()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
|
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
|
||||||
@@ -295,7 +311,9 @@ function accessArgs(a: AccessOptions): string[] {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
||||||
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
|
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
|
||||||
if (a.aria2cPath) args.push('--downloader', a.aria2cPath, '--downloader-args', ARIA2C_ARGS)
|
if (a.aria2cPath) {
|
||||||
|
args.push('--downloader', a.aria2cPath, '--downloader-args', aria2cArgs(a.aria2cConnections))
|
||||||
|
}
|
||||||
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
|
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
|
||||||
// only ever sets one (driven by Settings.cookieSource), but browser wins if
|
// only ever sets one (driven by Settings.cookieSource), but browser wins if
|
||||||
// a caller somehow sets both.
|
// a caller somehow sets both.
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
|||||||
proxy: settings.proxy,
|
proxy: settings.proxy,
|
||||||
rateLimit: settings.rateLimit,
|
rateLimit: settings.rateLimit,
|
||||||
aria2cPath,
|
aria2cPath,
|
||||||
|
aria2cConnections: settings.aria2cConnections,
|
||||||
// L136/M6: incognito attaches no cookies from the browser either.
|
// L136/M6: incognito attaches no cookies from the browser either.
|
||||||
cookiesFromBrowser:
|
cookiesFromBrowser:
|
||||||
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||||
|
|||||||
+4
-1
@@ -270,7 +270,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
// `time` (24h HH:MM) is validated inside setScheduledSync before touching schtasks (L51).
|
||||||
|
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean, time?: string) =>
|
||||||
|
setScheduledSync(enabled, time)
|
||||||
|
)
|
||||||
|
|
||||||
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
|
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
|
||||||
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
||||||
|
|||||||
+11
-4
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { getSystem32Path } from './binaries'
|
import { getSystem32Path } from './binaries'
|
||||||
import type { ScheduledSyncStatus } from '@shared/ipc'
|
import { isValidSyncTime, DEFAULT_SETTINGS, type ScheduledSyncStatus } from '@shared/ipc'
|
||||||
|
|
||||||
const TASK_NAME = 'AeroFetchDailySync'
|
const TASK_NAME = 'AeroFetchDailySync'
|
||||||
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
|
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
|
||||||
@@ -46,10 +46,17 @@ export async function getScheduledSync(): Promise<ScheduledSyncStatus> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
|
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
|
||||||
* `--sync` every day at 09:00 (overwriting any prior task of the same name).
|
* `--sync` every day at the given 24h 'HH:MM' time (overwriting any prior task of
|
||||||
|
* the same name — which is also how a time change re-registers, L51). The time is
|
||||||
|
* renderer-supplied over IPC, so it's validated here before reaching the schtasks
|
||||||
|
* argv; anything malformed falls back to the default rather than failing the toggle.
|
||||||
*/
|
*/
|
||||||
export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncStatus> {
|
export async function setScheduledSync(
|
||||||
|
enabled: boolean,
|
||||||
|
time?: string
|
||||||
|
): Promise<ScheduledSyncStatus> {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
const startTime = isValidSyncTime(time) ? time : DEFAULT_SETTINGS.syncTime
|
||||||
const tr = `"${process.execPath}" ${SYNC_FLAG}`
|
const tr = `"${process.execPath}" ${SYNC_FLAG}`
|
||||||
const r = await schtasks([
|
const r = await schtasks([
|
||||||
'/Create',
|
'/Create',
|
||||||
@@ -57,7 +64,7 @@ export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncS
|
|||||||
'/SC',
|
'/SC',
|
||||||
'DAILY',
|
'DAILY',
|
||||||
'/ST',
|
'/ST',
|
||||||
'09:00',
|
startTime,
|
||||||
'/TN',
|
'/TN',
|
||||||
TASK_NAME,
|
TASK_NAME,
|
||||||
'/TR',
|
'/TR',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
isYtdlpUpdateChannel,
|
isYtdlpUpdateChannel,
|
||||||
|
isValidSyncTime,
|
||||||
type Settings,
|
type Settings,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
type SponsorBlockCategory,
|
type SponsorBlockCategory,
|
||||||
@@ -266,6 +267,17 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
|||||||
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
|
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'aria2cConnections': {
|
||||||
|
// aria2c caps -x/-s at 16; clamp like maxConcurrent rather than reject (L64).
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isFinite(n)) s.set('aria2cConnections', Math.min(16, Math.max(1, Math.round(n))))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'syncTime':
|
||||||
|
// Reaches `schtasks /ST` when the daily sync is (re)registered — only a
|
||||||
|
// well-formed 24h HH:MM is ever stored (L51).
|
||||||
|
if (isValidSyncTime(value)) s.set('syncTime', value)
|
||||||
|
break
|
||||||
case 'clipboardWatch':
|
case 'clipboardWatch':
|
||||||
case 'useAria2c':
|
case 'useAria2c':
|
||||||
case 'restrictFilenames':
|
case 'restrictFilenames':
|
||||||
|
|||||||
@@ -218,11 +218,12 @@ const api = {
|
|||||||
* match the main function `syncWatchedSources` (L92). */
|
* match the main function `syncWatchedSources` (L92). */
|
||||||
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||||
|
|
||||||
/** Read / write the Windows daily-sync scheduled task. */
|
/** Read / write the Windows daily-sync scheduled task. `time` is the 24h 'HH:MM'
|
||||||
|
* start time (L51); omitted = the default. Re-creating with a new time updates it. */
|
||||||
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
||||||
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
|
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
|
||||||
setScheduledSync: (enabled: boolean): Promise<ScheduledSyncStatus> =>
|
setScheduledSync: (enabled: boolean, time?: string): Promise<ScheduledSyncStatus> =>
|
||||||
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled),
|
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled, time),
|
||||||
|
|
||||||
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
|
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
|
||||||
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
|
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const syncing = useSources((s) => s.syncing)
|
const syncing = useSources((s) => s.syncing)
|
||||||
const downloadItems = useDownloads((s) => s.items)
|
const downloadItems = useDownloads((s) => s.items)
|
||||||
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
|
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
|
||||||
|
const syncTime = useSettings((s) => s.syncTime)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
@@ -294,11 +295,26 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
async function toggleScheduled(next: boolean): Promise<void> {
|
async function toggleScheduled(next: boolean): Promise<void> {
|
||||||
setScheduled(next) // optimistic
|
setScheduled(next) // optimistic
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
const res = await window.api.setScheduledSync(next)
|
const res = await window.api.setScheduledSync(next, syncTime)
|
||||||
setScheduled(res.enabled)
|
setScheduled(res.enabled)
|
||||||
if (res.error) setError(res.error)
|
if (res.error) setError(res.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The native time input emits a complete 'HH:MM' or '' (cleared/partial) — only
|
||||||
|
// persist real times, so the picker can never store a malformed value (L51).
|
||||||
|
function onSyncTimeChange(value: string): void {
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return
|
||||||
|
updateSettings({ syncTime: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-register the task with the new time once editing settles (blur), so typing
|
||||||
|
// through the segments doesn't spawn schtasks per keystroke (L51).
|
||||||
|
async function onSyncTimeCommit(): Promise<void> {
|
||||||
|
if (!scheduled || PREVIEW) return
|
||||||
|
const res = await window.api.setScheduledSync(true, useSettings.getState().syncTime)
|
||||||
|
if (res.error) setError(res.error)
|
||||||
|
}
|
||||||
|
|
||||||
// Reset the selection (and any batch note) whenever the expanded source changes.
|
// Reset the selection (and any batch note) whenever the expanded source changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
@@ -578,6 +594,16 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
onChange={(_, d) => toggleScheduled(d.checked)}
|
onChange={(_, d) => toggleScheduled(d.checked)}
|
||||||
aria-label="Daily background sync"
|
aria-label="Daily background sync"
|
||||||
/>
|
/>
|
||||||
|
{scheduled && (
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
size="small"
|
||||||
|
value={syncTime}
|
||||||
|
onChange={(_, d) => onSyncTimeChange(d.value)}
|
||||||
|
onBlur={onSyncTimeCommit}
|
||||||
|
aria-label="Daily sync time"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Field, Input, Switch, Button, Card, Subtitle2, Spinner } from '@fluentui/react-components'
|
import {
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Switch,
|
||||||
|
SpinButton,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Subtitle2,
|
||||||
|
Spinner
|
||||||
|
} from '@fluentui/react-components'
|
||||||
import { GlobeRegular } from '@fluentui/react-icons'
|
import { GlobeRegular } from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../../store/settings'
|
import { useSettings } from '../../store/settings'
|
||||||
import { useSettingsStyles } from './settingsStyles'
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
@@ -9,6 +18,7 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
const proxy = useSettings((s) => s.proxy)
|
const proxy = useSettings((s) => s.proxy)
|
||||||
const rateLimit = useSettings((s) => s.rateLimit)
|
const rateLimit = useSettings((s) => s.rateLimit)
|
||||||
const useAria2c = useSettings((s) => s.useAria2c)
|
const useAria2c = useSettings((s) => s.useAria2c)
|
||||||
|
const aria2cConnections = useSettings((s) => s.aria2cConnections)
|
||||||
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
|
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
|
||||||
const youtubePoToken = useSettings((s) => s.youtubePoToken)
|
const youtubePoToken = useSettings((s) => s.youtubePoToken)
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
@@ -53,6 +63,25 @@ export function NetworkCard(): React.JSX.Element {
|
|||||||
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
|
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{useAria2c && (
|
||||||
|
<Field
|
||||||
|
label="aria2c connections"
|
||||||
|
hint="Parallel connections per download (1–16). More can be faster on throttled connections; fewer is gentler on servers."
|
||||||
|
>
|
||||||
|
<SpinButton
|
||||||
|
value={aria2cConnections}
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
const v = d.value ?? Number(d.displayValue)
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
update({ aria2cConnections: Math.min(16, Math.max(1, Math.round(v))) })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="YouTube client (advanced)"
|
label="YouTube client (advanced)"
|
||||||
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
|
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
|
||||||
|
|||||||
@@ -319,6 +319,15 @@ export function isYtdlpUpdateChannel(v: unknown): v is YtdlpUpdateChannel {
|
|||||||
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
|
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime guard for a 24h 'HH:MM' sync time crossing the IPC boundary (L51) —
|
||||||
|
* the value reaches `schtasks /ST`, so anything malformed must never be forwarded.
|
||||||
|
* Shared so main's settings validation and schedule.ts use one definition.
|
||||||
|
*/
|
||||||
|
export function isValidSyncTime(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v)
|
||||||
|
}
|
||||||
|
|
||||||
export interface YtdlpUpdateResult {
|
export interface YtdlpUpdateResult {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
/** yt-dlp's own update output, e.g. "Updated to ... " or "yt-dlp is up to date" */
|
/** yt-dlp's own update output, e.g. "Updated to ... " or "yt-dlp is up to date" */
|
||||||
@@ -570,6 +579,8 @@ export interface Settings {
|
|||||||
rateLimit: string
|
rateLimit: string
|
||||||
/** use bundled aria2c (resources/bin/aria2c.exe) as the external downloader */
|
/** use bundled aria2c (resources/bin/aria2c.exe) as the external downloader */
|
||||||
useAria2c: boolean
|
useAria2c: boolean
|
||||||
|
/** aria2c parallel connections per download (-x/-s), 1–16; only used when useAria2c is on (L64) */
|
||||||
|
aria2cConnections: number
|
||||||
/** where auth cookies come from: none, a browser's store, or the sign-in window */
|
/** where auth cookies come from: none, a browser's store, or the sign-in window */
|
||||||
cookieSource: CookieSource
|
cookieSource: CookieSource
|
||||||
/** which browser to read cookies from when cookieSource is 'browser' */
|
/** which browser to read cookies from when cookieSource is 'browser' */
|
||||||
@@ -603,6 +614,8 @@ export interface Settings {
|
|||||||
notifyOnComplete: boolean
|
notifyOnComplete: boolean
|
||||||
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
|
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
|
||||||
autoDownloadNew: boolean
|
autoDownloadNew: boolean
|
||||||
|
/** daily watched-source sync start time, 24h 'HH:MM' (Task Scheduler /ST) (L51) */
|
||||||
|
syncTime: string
|
||||||
/** false until the first-run welcome screen has been dismissed */
|
/** false until the first-run welcome screen has been dismissed */
|
||||||
hasCompletedOnboarding: boolean
|
hasCompletedOnboarding: boolean
|
||||||
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
||||||
@@ -650,6 +663,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
proxy: '',
|
proxy: '',
|
||||||
rateLimit: '',
|
rateLimit: '',
|
||||||
useAria2c: false,
|
useAria2c: false,
|
||||||
|
aria2cConnections: 16,
|
||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
youtubePlayerClient: '',
|
youtubePlayerClient: '',
|
||||||
@@ -670,6 +684,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
// Default off so adding a watched channel doesn't silently fill the user's
|
// Default off so adding a watched channel doesn't silently fill the user's
|
||||||
// disk on first use (SR3). Enable explicitly once they know what it does.
|
// disk on first use (SR3). Enable explicitly once they know what it does.
|
||||||
autoDownloadNew: false,
|
autoDownloadNew: false,
|
||||||
|
syncTime: '09:00',
|
||||||
hasCompletedOnboarding: false,
|
hasCompletedOnboarding: false,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
launchAtStartup: false,
|
launchAtStartup: false,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
collectionOutputTemplate,
|
collectionOutputTemplate,
|
||||||
CROP_SQUARE_PPA,
|
CROP_SQUARE_PPA,
|
||||||
ARIA2C_ARGS,
|
ARIA2C_ARGS,
|
||||||
|
aria2cArgs,
|
||||||
DEST_MARKER,
|
DEST_MARKER,
|
||||||
type AccessOptions
|
type AccessOptions
|
||||||
} from '../src/main/buildArgs'
|
} from '../src/main/buildArgs'
|
||||||
@@ -105,6 +106,25 @@ describe('network options', () => {
|
|||||||
const argv = build(opts(), dlo(), NO_ACCESS)
|
const argv = build(opts(), dlo(), NO_ACCESS)
|
||||||
expect(argv).not.toContain('--downloader-args')
|
expect(argv).not.toContain('--downloader-args')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('threads a custom aria2c connection count into --downloader-args (L64)', () => {
|
||||||
|
const argv = build(opts(), dlo(), {
|
||||||
|
...NO_ACCESS,
|
||||||
|
aria2cPath: 'C:/fake/bin/aria2c.exe',
|
||||||
|
aria2cConnections: 4
|
||||||
|
})
|
||||||
|
expect(hasSeq(argv, '--downloader-args', 'aria2c:-x 4 -s 4 -k 1M')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aria2cArgs clamps to aria2c’s 1–16 ceiling and defaults to 16 (L64)', () => {
|
||||||
|
expect(aria2cArgs()).toBe('aria2c:-x 16 -s 16 -k 1M')
|
||||||
|
expect(aria2cArgs(8)).toBe('aria2c:-x 8 -s 8 -k 1M')
|
||||||
|
expect(aria2cArgs(0)).toBe('aria2c:-x 1 -s 1 -k 1M') // below floor → 1
|
||||||
|
expect(aria2cArgs(99)).toBe('aria2c:-x 16 -s 16 -k 1M') // above ceiling → 16
|
||||||
|
expect(aria2cArgs(7.6)).toBe('aria2c:-x 8 -s 8 -k 1M') // rounds
|
||||||
|
expect(aria2cArgs(NaN)).toBe('aria2c:-x 16 -s 16 -k 1M') // non-finite → default
|
||||||
|
expect(ARIA2C_ARGS).toBe(aria2cArgs()) // the exported default stays in sync
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Cookies: browser store vs. sign-in window's exported file --------------
|
// --- Cookies: browser store vs. sign-in window's exported file --------------
|
||||||
|
|||||||
+34
-1
@@ -8,7 +8,40 @@ import {
|
|||||||
isValidSource,
|
isValidSource,
|
||||||
isValidMediaItem
|
isValidMediaItem
|
||||||
} from '../src/main/validation'
|
} from '../src/main/validation'
|
||||||
import type { HistoryEntry, ErrorLogEntry, Source, MediaItem } from '@shared/ipc'
|
import {
|
||||||
|
isValidSyncTime,
|
||||||
|
type HistoryEntry,
|
||||||
|
type ErrorLogEntry,
|
||||||
|
type Source,
|
||||||
|
type MediaItem
|
||||||
|
} from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- L51: daily-sync time (reaches `schtasks /ST`) ---------------------------
|
||||||
|
|
||||||
|
describe('isValidSyncTime', () => {
|
||||||
|
it('accepts well-formed 24h HH:MM times', () => {
|
||||||
|
expect(isValidSyncTime('00:00')).toBe(true)
|
||||||
|
expect(isValidSyncTime('09:00')).toBe(true)
|
||||||
|
expect(isValidSyncTime('19:30')).toBe(true)
|
||||||
|
expect(isValidSyncTime('23:59')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects out-of-range, partial, and 12h forms', () => {
|
||||||
|
expect(isValidSyncTime('24:00')).toBe(false)
|
||||||
|
expect(isValidSyncTime('12:60')).toBe(false)
|
||||||
|
expect(isValidSyncTime('9:00')).toBe(false) // must be zero-padded
|
||||||
|
expect(isValidSyncTime('09:0')).toBe(false)
|
||||||
|
expect(isValidSyncTime('9am')).toBe(false)
|
||||||
|
expect(isValidSyncTime('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-strings and injection-shaped values', () => {
|
||||||
|
expect(isValidSyncTime(undefined)).toBe(false)
|
||||||
|
expect(isValidSyncTime(900)).toBe(false)
|
||||||
|
expect(isValidSyncTime('09:00 /TN evil')).toBe(false) // nothing may trail the time
|
||||||
|
expect(isValidSyncTime('09:00\n')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// --- S4: filename template path-traversal -----------------------------------
|
// --- S4: filename template path-traversal -----------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user