661a4e7572
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:
- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
(non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
and toasts the main-process reason instead of silently no-oping on a moved/typed-
out file. safeShowInFolder upgraded to return an error string like safeOpenPath
(preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
active (downloading + queued) count from any tab, via a count-only App selector
that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".
Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).
Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
1.8 KiB
TypeScript
64 lines
1.8 KiB
TypeScript
import { shell } from 'electron'
|
|
import { existsSync, statSync } from 'fs'
|
|
import { extname, isAbsolute } from 'path'
|
|
|
|
/**
|
|
* Open/reveal helpers used by the shell:* IPC handlers.
|
|
*
|
|
* The renderer supplies the path (it originates from yt-dlp's after-move print),
|
|
* but the IPC boundary must not trust it blindly: a compromised renderer could
|
|
* otherwise call openPath() on an arbitrary executable and have the OS run it.
|
|
* So openPath is confined to existing files with a known media extension —
|
|
* never .exe/.bat/.ps1/etc.
|
|
*/
|
|
const OPENABLE_EXTENSIONS = new Set([
|
|
// video
|
|
'.mp4',
|
|
'.mkv',
|
|
'.webm',
|
|
'.mov',
|
|
'.avi',
|
|
'.flv',
|
|
'.ts',
|
|
'.m4v',
|
|
'.3gp',
|
|
'.ogv',
|
|
// audio
|
|
'.mp3',
|
|
'.m4a',
|
|
'.opus',
|
|
'.ogg',
|
|
'.oga',
|
|
'.aac',
|
|
'.flac',
|
|
'.wav',
|
|
'.wma',
|
|
// subtitle sidecars (plain text — safe to open)
|
|
'.vtt',
|
|
'.srt'
|
|
])
|
|
|
|
/** Open a downloaded media file with its default app. Returns '' on success,
|
|
* or an error string (matching shell.openPath's contract). */
|
|
export async function safeOpenPath(p: unknown): Promise<string> {
|
|
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
|
|
if (!OPENABLE_EXTENSIONS.has(extname(p).toLowerCase())) {
|
|
return 'Refusing to open this file type.'
|
|
}
|
|
try {
|
|
if (!statSync(p).isFile()) return 'Not a file.'
|
|
} catch {
|
|
return 'File not found.'
|
|
}
|
|
return shell.openPath(p)
|
|
}
|
|
|
|
/** Reveal a path in the OS file manager. Returns '' on success, or an error
|
|
* string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
|
|
export function safeShowInFolder(p: unknown): string {
|
|
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
|
|
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
|
|
shell.showItemInFolder(p)
|
|
return ''
|
|
}
|