Fix L16/L75/L77: datetime formatting, update-token visibility, ffmpeg re-check
L16: relTime now caps at weeks/months instead of unbounded 'N d ago' (adds w/mo
buckets). formatWhen omits the year when the timestamp is in the current year.
L75: Update-token Field in the Software-update card is hidden by default; only
shown when a token is already configured or an update-check error occurred.
L77: Adds a Re-check button next to the ffmpeg/ffprobe version display so a
previously-not-found binary can be re-detected without restarting the app.
typecheck + 242 tests + eslint + prettier green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -1,4 +1,4 @@
|
||||
# Code Audit
|
||||
# Code Audit
|
||||
|
||||
Living checklist of audit findings for AeroFetch. Security & correctness were reviewed
|
||||
2026-06-23 (all fixed — see Completed). The architectural review (2026-06-29) added the
|
||||
@@ -408,7 +408,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
||||
rings; `box-sizing` is then set ad hoc on the SettingsView swatch.
|
||||
- [x] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is
|
||||
already that union).
|
||||
- [ ] **L16 — `relTime`/`formatWhen` verbosity.** `relTime` returns unbounded "N d ago"; History's
|
||||
- [x] **L16 — `relTime`/`formatWhen` verbosity.** `relTime` returns unbounded "N d ago"; History's
|
||||
`formatWhen` always appends the year, even for the current year.
|
||||
- [x] **L17 — TemplateManager regex not validated.** An invalid `urlPattern` is accepted with no
|
||||
feedback and silently never matches (main's `matchesUrl` swallows the error).
|
||||
@@ -516,11 +516,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
|
||||
re-queued row loses its thumbnail until re-probed.
|
||||
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
|
||||
- [ ] **L74 — Accent swatches are triple-labeled** (`aria-pressed` + `aria-label` + `title`).
|
||||
- [ ] **L75 — Update-token field always visible** in the Software-update card, even when no update
|
||||
- [x] **L75 — Update-token field always visible** in the Software-update card, even when no update
|
||||
is pending — buries an advanced/rarely-needed input.
|
||||
- [x] **L76 — Duplicate-warning may show a placeholder title** — `setDup(existing.title)` can be the
|
||||
`titleFromUrl` placeholder before metadata resolves ('YouTube video (id)').
|
||||
- [ ] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
|
||||
- [x] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
|
||||
with no retry) unlike the yt-dlp "Check version" button.
|
||||
- [ ] **L78 — `DownloadsView` empty-state copy** ("Paste a URL above to get started") doesn't match
|
||||
the actual two-step Fetch→Download affordance.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
@@ -153,10 +153,10 @@ const useStyles = makeStyles({
|
||||
}
|
||||
})
|
||||
|
||||
// Combined "Type — Quality" options for the default-format dropdown.
|
||||
// Combined "Type -- Quality" options for the default-format dropdown.
|
||||
const FORMAT_OPTIONS = [
|
||||
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video — ${q}` })),
|
||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
|
||||
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
|
||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
|
||||
]
|
||||
|
||||
const THEME_MODE_OPTIONS = [
|
||||
@@ -364,7 +364,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
const result = await window.api.importBackup()
|
||||
setImportResult(result)
|
||||
if (result.ok) {
|
||||
// Settings + templates changed underneath the stores — reload both.
|
||||
// Settings + templates changed underneath the stores -- reload both.
|
||||
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
|
||||
useSettings.setState({ ...s, loaded: true })
|
||||
useTemplates.setState({ templates: t })
|
||||
@@ -378,7 +378,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
function copyErrorReport(): void {
|
||||
// The button is disabled when there are no entries, so `report` is always
|
||||
// non-empty here — no need for an unreachable "No errors logged." fallback (L159).
|
||||
// non-empty here -- no need for an unreachable "No errors logged." fallback (L159).
|
||||
const report = errorEntries
|
||||
.map((e) =>
|
||||
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
|
||||
@@ -393,8 +393,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
try {
|
||||
const result = await window.api.cookiesLogin(loginUrl)
|
||||
if (result.ok && result.cookieCount === 0) {
|
||||
// Window closed without capturing anything — don't imply success (L50).
|
||||
setLoginError('No cookies were captured — did you sign in before closing the window?')
|
||||
// Window closed without capturing anything -- don't imply success (L50).
|
||||
setLoginError('No cookies were captured -- did you sign in before closing the window?')
|
||||
} else if (result.ok) {
|
||||
setCookiesStatus(await window.api.cookiesStatus())
|
||||
} else {
|
||||
@@ -441,7 +441,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
try {
|
||||
const result = await window.api.updateYtdlp(ytdlpChannel)
|
||||
setUpdateResult(result)
|
||||
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
|
||||
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
|
||||
} catch (e) {
|
||||
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
@@ -468,7 +468,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
/>
|
||||
{noResults && (
|
||||
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
|
||||
No settings match “{search}”.
|
||||
No settings match "{search}".
|
||||
</Caption1>
|
||||
)}
|
||||
</div>
|
||||
@@ -577,7 +577,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Keep running in the tray"
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting — so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting -- so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
|
||||
>
|
||||
<Switch
|
||||
checked={minimizeToTray}
|
||||
@@ -588,7 +588,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Start with Windows"
|
||||
hint="Launch AeroFetch automatically when you sign in — useful with auto-download so watched channels stay current in the background."
|
||||
hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={launchAtStartup}
|
||||
@@ -635,7 +635,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Caption1 className={styles.hint}>
|
||||
{highContrast
|
||||
? 'A Windows high-contrast theme is active — AeroFetch follows your system colors.'
|
||||
? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
|
||||
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
|
||||
</Caption1>
|
||||
{highContrast && (
|
||||
@@ -746,7 +746,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
setPotHint('Token saved.')
|
||||
} else {
|
||||
setPotHint(
|
||||
'Token not found — try signing into YouTube first via Cookies above.'
|
||||
'Token not found -- try signing into YouTube first via Cookies above.'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@@ -799,7 +799,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
<>
|
||||
<Field
|
||||
label="Site to sign in to"
|
||||
hint="Opens a sign-in window. Log in, then close the window — your cookies are saved automatically."
|
||||
hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
@@ -838,7 +838,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Custom commands</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Named templates of extra yt-dlp flags — your own power-user recipes (e.g.
|
||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
|
||||
option, so a template flag can override a setting above it.
|
||||
</Caption1>
|
||||
@@ -891,7 +891,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Restrict filenames"
|
||||
hint="Sanitize titles to plain ASCII letters/digits, no spaces — safer for old filesystems and shells."
|
||||
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
|
||||
>
|
||||
<Switch
|
||||
checked={restrictFilenames}
|
||||
@@ -919,7 +919,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||
machine. Does not include download history or credentials (proxy, API tokens — re-enter
|
||||
machine. Does not include download history or credentials (proxy, API tokens -- re-enter
|
||||
those after import).
|
||||
</Caption1>
|
||||
|
||||
@@ -1034,23 +1034,25 @@ export function SettingsView(): React.JSX.Element {
|
||||
{appChecking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional — access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
</Field>
|
||||
{(updateToken.trim() !== '' || !!appUpdError) && (
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional -- access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{appUpd?.ok && appUpd.available && (
|
||||
<>
|
||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||
Version {appUpd.latestVersion} is available — here's what changed:
|
||||
Version {appUpd.latestVersion} is available -- here's what changed:
|
||||
</Text>
|
||||
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
|
||||
<div className={styles.folderRow}>
|
||||
@@ -1073,7 +1075,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
</div>
|
||||
{!appUpd.downloadUrl && (
|
||||
<Caption1 className={styles.hint}>
|
||||
This release has no installer attached — use “View release” to download it manually.
|
||||
This release has no installer attached -- use "View release" to download it
|
||||
manually.
|
||||
</Caption1>
|
||||
)}
|
||||
{appDownloading && <ProgressBar value={appFraction} />}
|
||||
@@ -1081,7 +1084,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
{appUpd?.ok && !appUpd.available && (
|
||||
<Text>You're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||||
<Text>You're up to date -- v{appUpd.currentVersion} is the latest.</Text>
|
||||
)}
|
||||
|
||||
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
|
||||
@@ -1115,14 +1118,24 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
|
||||
)}
|
||||
{ffmpeg && (
|
||||
<>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||||
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||||
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
||||
</Text>
|
||||
</>
|
||||
<div className={styles.folderRow}>
|
||||
<div>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() =>
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||
}
|
||||
aria-label="Re-check ffmpeg versions"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{ytdlpLastUpdateCheck > 0 && (
|
||||
<Caption1 className={styles.hint}>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
|
||||
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
|
||||
|
||||
/** Relative "time since" label, e.g. "just now" / "5 min ago" / "3 h ago" /
|
||||
* "2 d ago". Returns "never" for a missing timestamp. (Library last-indexed.) */
|
||||
/** Relative "time since" label: "just now" / "5 min ago" / "3 h ago" /
|
||||
* "2 d ago" / "3 w ago" / "2 mo ago". Returns "never" for a missing
|
||||
* timestamp. (Library last-indexed.) */
|
||||
export function relTime(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const mins = Math.round((Date.now() - ms) / 60000)
|
||||
@@ -11,11 +12,15 @@ export function relTime(ms?: number): string {
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} d ago`
|
||||
const days = Math.round(hrs / 24)
|
||||
if (days < 7) return `${days} d ago`
|
||||
const weeks = Math.round(days / 7)
|
||||
if (weeks < 5) return `${weeks} w ago`
|
||||
return `${Math.round(days / 30)} mo ago`
|
||||
}
|
||||
|
||||
/** Absolute "when" label that stays short for recent items: "Today, 3:04 PM" /
|
||||
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (History rows.) */
|
||||
/** Absolute "when" label: "Today, 3:04 PM" / "Yesterday, 3:04 PM" /
|
||||
* "Jun 5" (current year) / "Jun 5, 2024" (past year). (History rows.) */
|
||||
export function formatWhen(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -24,7 +29,12 @@ export function formatWhen(ts: number): string {
|
||||
const dayMs = 1000 * 60 * 60 * 24
|
||||
if (ts >= startOfToday) return `Today, ${time}`
|
||||
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const sameYear = d.getFullYear() === now.getFullYear()
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' })
|
||||
})
|
||||
}
|
||||
|
||||
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".
|
||||
|
||||
Reference in New Issue
Block a user