Complete Phase E theming, onboarding, and Windows share integration

Adds theme presets (Toffee/Slate/Evergreen/Lavender) with a "follow system"
mode and high-contrast awareness, a first-run welcome screen, and the
Windows analog of Android's share sheet for a Win32 app: an aerofetch://
protocol handler plus an Explorer "Send to AeroFetch" entry, both routed
through a single-instance lock so a second launch hands its link to the
already-running window instead of opening a duplicate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 08:45:09 -04:00
parent a822a2bb52
commit fa78b13cac
17 changed files with 883 additions and 77 deletions
+60 -8
View File
@@ -155,18 +155,70 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
Some items are Android-specific in Seal and adapted to Windows here.
- [ ] **Theme presets + "follow system"** and high-contrast (toffee light/dark already exists;
add system-follow + a few accent presets). *Material-You dynamic color is Android-only;
closest Windows analog is accent presets / system accent.*
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional.
- [ ] **Windows "open with" / share integration** — register a URL-protocol handler or Explorer
"Send to" entry (analog of Android's share sheet). *Lower priority, platform-specific.*
- [ ] **Onboarding / welcome screen** on first run.
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee
(original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing
toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
the renderer over a new `system-theme:update` channel into
`src/renderer/src/store/systemTheme.ts`) so the native window background stays in sync
too, not just the in-page theme. High contrast: `nativeTheme.shouldUseHighContrastColors`
surfaces as a status row in Settings → Appearance with a shortcut to Windows' contrast
settings (`ms-settings:easeofaccess-highcontrast`); actual color overrides are left to
Chromium's native `forced-colors` handling since nothing in the app sets
`forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it
sets an explicit light/dark away from whatever's currently resolved, breaking out of
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually.
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
since shipping ~40 real translations isn't something to fake; doing this properly means
wiring up an i18n library + extracting every string now, with translation content
arriving incrementally from contributors.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
install time via `electron-builder.yml`'s `protocols` field (NSIS), and at runtime via
`app.setAsDefaultProtocolClient` in `src/main/index.ts` (covers the portable build and
dev, which have no installer step to do it for them).
- **Explorer "Send to AeroFetch"** — `src/main/deeplink.ts`'s `registerSendToShortcut()`
writes a `.lnk` into `%APPDATA%\Microsoft\Windows\SendTo` via Electron's
`shell.writeShortcutLink` (no native deps). Sending a `.url` Internet Shortcut file
(what Explorer/browsers create from "Create shortcut"/dragging a tab to the desktop)
passes its path as argv; `extractIncomingUrl()` parses the `URL=` line.
Both paths funnel through the same `extractIncomingUrl()` (only ever forwards http(s)
targets — same restriction as the existing external-link window-open handler) and a new
`external-url` IPC push channel. The renderer surfaces the incoming link via
`DownloadBar.tsx`'s existing clipboard-suggestion banner (now labeled "Link received:"
for this source instead of "Use copied link?") rather than a new UI element. Added
`app.requestSingleInstanceLock()` + a `second-instance` handler so a second invocation
(the OS relaunching us for the protocol/SendTo case) hands its link to the already-running
window instead of opening a duplicate. `extractIncomingUrl()`'s parsing/validation logic
has 10 new unit tests (`test/deeplink.test.ts`); typecheck and the full suite are clean.
*Caveat: the OS-level wiring (protocol registration, second-instance routing, the SendTo
shortcut) is main-process/Electron-only and can't be exercised in the Vite browser
preview used elsewhere in this phase — worth a real install + a manual
`aerofetch://download?url=...` / "Send to" smoke test, same spirit as Phases AC's
real-download caveats.*
- [x] **Onboarding / welcome screen** on first run. `Settings.hasCompletedOnboarding`
(default `false`, persisted) gates a full-screen `Onboarding.tsx` rendered in place of
the sidebar + main content in `App.tsx` — not a Fluent `Dialog`, since this app avoids
Fluent's portal-based overlays entirely (GPU/driver blank-overlay issue noted in
`src/main/index.ts` and `Select.tsx`). Shows the brand mark, a one-line pitch, the
download-folder picker (same field as Settings → Downloads), three tip bullets, and a
"Get started" button that flips the flag. Gated on the settings store's `loaded` flag
so a returning user's real settings can never get clobbered by a one-frame flash of the
default-false state. Verified in the UI preview (typecheck + `npm run test` clean):
screen renders, folder field/browse work, "Get started" dismisses into the normal app.
---
## Not portable from Seal (noted for completeness)
- SAF directory picker (Android storage) → already covered by the native folder dialog.
- Android share-sheet / quick-download-on-share → partially mapped to Phase E.
- Android share-sheet / quick-download-on-share → mapped to Phase E's `aerofetch://` protocol
+ Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope
for this app's NSIS/portable distribution).
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
+9
View File
@@ -2,6 +2,15 @@ appId: com.aerofetch.app
productName: AeroFetch
copyright: yt-dlp frontend
# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded>
# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent.
# The NSIS installer registers this at install time; main/index.ts's
# app.setAsDefaultProtocolClient call additionally covers the portable build and dev.
protocols:
- name: AeroFetch
schemes:
- aerofetch
directories:
buildResources: build
output: dist
+85
View File
@@ -0,0 +1,85 @@
import { app, shell, type BrowserWindow } from 'electron'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
/** Only ever forward http(s) targets into the app — same restriction the
* external-link window-open handler in index.ts applies to in-page links. */
function asHttpUrl(candidate: string): string | null {
try {
const u = new URL(candidate)
return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null
} catch {
return null
}
}
/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's
* "Send to" menu hands us when the user sends a saved link to AeroFetch. */
function readUrlShortcut(path: string): string | null {
try {
const text = readFileSync(path, 'utf8')
const match = /^URL=(.+)$/im.exec(text)
return match ? asHttpUrl(match[1].trim()) : null
} catch {
return null
}
}
/**
* Pulls a download target out of process argv — either the `aerofetch://`
* custom protocol (`aerofetch://download?url=<encoded>`) or a `.url` Internet
* Shortcut path Explorer's "Send to" menu passes us (see registerSendToShortcut).
*/
export function extractIncomingUrl(argv: string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('aerofetch://')) {
try {
const target = new URL(arg).searchParams.get('url')
const valid = target && asHttpUrl(target)
if (valid) return valid
} catch {
/* malformed protocol invocation — ignore */
}
} else if (/\.url$/i.test(arg) && existsSync(arg)) {
const target = readUrlShortcut(arg)
if (target) return target
}
}
return null
}
/**
* Adds/refreshes a "Send to AeroFetch" entry in Explorer's SendTo menu — the
* closest Windows analog to Android's share sheet a Win32 (non-MSIX) app can
* offer; registering as an actual Share Target requires an MSIX package.
* Best-effort and idempotent (re-running just overwrites the same shortcut),
* so a failure here should never block startup.
*/
export function registerSendToShortcut(): void {
if (process.platform !== 'win32') return
try {
const shortcutPath = join(
app.getPath('appData'),
'Microsoft',
'Windows',
'SendTo',
'AeroFetch.lnk'
)
shell.writeShortcutLink(shortcutPath, {
target: process.execPath,
description: 'Send to AeroFetch',
icon: process.execPath,
iconIndex: 0
})
} catch {
/* SendTo integration is a nice-to-have */
}
}
/** Focuses (and un-minimizes) an existing window — used when a second launch
* (protocol or SendTo) should hand its URL to the already-running instance. */
export function focusWindow(win: BrowserWindow): void {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
+113 -27
View File
@@ -1,5 +1,5 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard } from 'electron'
import { join } from 'path'
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
import { join, resolve } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import {
IpcChannels,
@@ -7,7 +7,8 @@ import {
type Settings,
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel
type YtdlpUpdateChannel,
type SystemThemeInfo
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
@@ -20,6 +21,13 @@ import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
// this instance via 'second-instance' below, instead of opening a duplicate window.
const isPrimaryInstance = app.requestSingleInstanceLock()
if (!isPrimaryInstance) app.quit()
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
@@ -38,18 +46,45 @@ app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
// before the app is ready and before any user path is read.
setupPortableData()
// Register aerofetch:// so a browser/another app can hand AeroFetch a link
// (?url=<encoded>) the way Android's share sheet hands Seal one. The NSIS
// installer also declares this scheme (electron-builder.yml's `protocols`)
// so it's registered even before first launch; this call additionally covers
// the portable build and dev, which have no installer step to do it for us.
if (is.dev && process.argv.length >= 2) {
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])])
} else {
app.setAsDefaultProtocolClient('aerofetch')
}
let mainWindow: BrowserWindow | null = null
// Page background per theme — keep in sync with `pageBackground` in
// src/renderer/src/theme.ts. Set as the window's NATIVE background so the
// one-frame compositor repaint (when a tooltip/dropdown overlay first paints on
// Windows) shows the app's current color instead of a mismatched white flash.
const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const
// 'system' isn't a real background — resolve it against the OS's current
// preference (nativeTheme.themeSource defaults to 'system', so this tracks it
// without AeroFetch ever touching themeSource itself).
function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
}
function getSystemThemeInfo(): SystemThemeInfo {
return {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
}
}
function createWindow(): void {
const mainWindow = new BrowserWindow({
const win = new BrowserWindow({
width: 920,
height: 700,
show: false,
backgroundColor: THEME_BACKGROUND[getSettings().theme],
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
@@ -58,14 +93,29 @@ function createWindow(): void {
nodeIntegration: false
}
})
mainWindow = win
mainWindow.on('ready-to-show', () => {
mainWindow.show()
win.on('ready-to-show', () => {
win.show()
})
win.on('closed', () => {
if (mainWindow === win) mainWindow = null
})
// The OS may have launched us with an aerofetch:// link or a "Send to" .url
// file on the command line — hand it to DownloadBar's link-suggestion banner
// once the page (and its IPC listener) is actually ready to receive it.
const incomingUrl = extractIncomingUrl(process.argv)
if (incomingUrl) {
win.webContents.once('did-finish-load', () => {
win.webContents.send(IpcChannels.externalUrl, incomingUrl)
})
}
// Open external links in the OS browser, never in-app — and only http(s), so a
// file:// or custom-protocol URL can't be used to launch a local handler.
mainWindow.webContents.setWindowOpenHandler((details) => {
win.webContents.setWindowOpenHandler((details) => {
try {
const { protocol } = new URL(details.url)
if (protocol === 'http:' || protocol === 'https:') shell.openExternal(details.url)
@@ -77,12 +127,12 @@ function createWindow(): void {
// The app shell is local and self-contained — never let the renderer navigate
// away from it (defence in depth; HMR uses websockets, not navigation).
mainWindow.webContents.on('will-navigate', (e) => e.preventDefault())
win.webContents.on('will-navigate', (e) => e.preventDefault())
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
win.loadFile(join(__dirname, '../renderer/index.html'))
}
}
@@ -130,11 +180,19 @@ function registerIpcHandlers(): void {
// repaint never flashes a mismatched color behind an overlay. Use the
// validated result, not the raw partial (which may hold a bogus value).
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme])
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
)
}
return result
})
ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo())
ipcMain.handle(IpcChannels.openHighContrastSettings, () =>
shell.openExternal('ms-settings:easeofaccess-highcontrast')
)
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
@@ -166,28 +224,56 @@ function registerIpcHandlers(): void {
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background in sync the same way settingsSet does.
if (result.ok) win?.setBackgroundColor(THEME_BACKGROUND[getSettings().theme])
if (result.ok) {
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
}
return result
})
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
// Push OS theme/contrast changes to every window, and keep the native
// background in sync for windows currently following 'system'.
function registerSystemThemeBridge(): void {
nativeTheme.on('updated', () => {
const info = getSystemThemeInfo()
const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IpcChannels.systemThemeUpdate, info)
if (getSettings().theme === 'system') win.setBackgroundColor(bg)
}
})
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
if (isPrimaryInstance) {
// A second launch arrives here as argv, not a real new process — extract any
// link it carried and hand it to the window we already have.
app.on('second-instance', (_e, argv) => {
if (!mainWindow) return
focusWindow(mainWindow)
const incomingUrl = extractIncomingUrl(argv)
if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl)
})
registerIpcHandlers()
createWindow()
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
registerSystemThemeBridge()
registerSendToShortcut()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
}
+11 -2
View File
@@ -7,6 +7,7 @@ import {
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS,
type Settings,
type DownloadOptions,
@@ -21,6 +22,7 @@ const DEFAULTS: Settings = {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'toffee',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
@@ -32,7 +34,8 @@ const DEFAULTS: Settings = {
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
notifyOnComplete: true,
hasCompletedOnboarding: false
}
/** Fixed path for the --download-archive file; not user-configurable. */
@@ -104,7 +107,12 @@ export function setSettings(partial: Partial<Settings>): Settings {
if (value === undefined) continue
switch (key) {
case 'theme':
if (value === 'light' || value === 'dark') s.set('theme', value)
if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value)
break
case 'accentColor':
if ((ACCENT_COLORS as readonly string[]).includes(value as string)) {
s.set('accentColor', value as Settings['accentColor'])
}
break
case 'defaultKind':
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
@@ -120,6 +128,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
case 'defaultTemplateId':
+23 -1
View File
@@ -16,7 +16,8 @@ import {
type CommandPreviewResult,
type ErrorLogEntry,
type BackupExportResult,
type BackupImportResult
type BackupImportResult,
type SystemThemeInfo
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -99,6 +100,27 @@ const api = {
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.downloadEvent, listener)
return () => ipcRenderer.removeListener(IpcChannels.downloadEvent, listener)
},
getSystemTheme: (): Promise<SystemThemeInfo> => ipcRenderer.invoke(IpcChannels.systemThemeGet),
/** Subscribe to OS theme/contrast changes. Returns an unsubscribe function. */
onSystemThemeUpdate: (cb: (info: SystemThemeInfo) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, info: SystemThemeInfo): void => cb(info)
ipcRenderer.on(IpcChannels.systemThemeUpdate, listener)
return () => ipcRenderer.removeListener(IpcChannels.systemThemeUpdate, listener)
},
/** Opens Windows' "Contrast themes" accessibility settings page. */
openHighContrastSettings: (): Promise<void> =>
ipcRenderer.invoke(IpcChannels.openHighContrastSettings),
/** Subscribe to links handed to AeroFetch from outside (aerofetch:// or a
* "Send to" .url file). Returns an unsubscribe function. */
onExternalUrl: (cb: (url: string) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, url: string): void => cb(url)
ipcRenderer.on(IpcChannels.externalUrl, listener)
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
}
}
+33 -14
View File
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { HistoryView } from './components/HistoryView'
import { SettingsView } from './components/SettingsView'
import { lightTheme, darkTheme, pageBackground } from './theme'
import { Onboarding } from './components/Onboarding'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
const useStyles = makeStyles({
provider: {
@@ -27,12 +29,22 @@ const useStyles = makeStyles({
function App(): React.JSX.Element {
const styles = useStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const isDark = theme === 'dark'
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const [tab, setTab] = useState<TabValue>('downloads')
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
return (
<FluentProvider theme={isDark ? darkTheme : lightTheme} className={styles.provider}>
<FluentProvider
theme={getTheme(accentColor, isDark ? 'dark' : 'light')}
className={styles.provider}
>
<div
className={styles.root}
style={{
@@ -40,18 +52,25 @@ function App(): React.JSX.Element {
colorScheme: isDark ? 'dark' : 'light'
}}
>
<Sidebar
tab={tab}
onTabChange={setTab}
isDark={isDark}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
/>
{showOnboarding ? (
<Onboarding />
) : (
<>
<Sidebar
tab={tab}
onTabChange={setTab}
isDark={isDark}
followingSystem={theme === 'system'}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />}
</main>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />}
</main>
</>
)}
</div>
</FluentProvider>
)
+22 -2
View File
@@ -337,7 +337,10 @@ export function DownloadBar(): React.JSX.Element {
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing).
// The same banner also surfaces links handed to AeroFetch from outside — the
// aerofetch:// protocol or a "Send to" .url file (see onExternalUrl below).
const [suggestion, setSuggestion] = useState<string | null>(null)
const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard')
const urlRef = useRef('')
urlRef.current = url
const lastSeen = useRef<string | null>(null)
@@ -354,7 +357,10 @@ export function DownloadBar(): React.JSX.Element {
}
if (!active) return
text = text.trim()
if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text)
if (looksLikeUrl(text) && text !== lastSeen.current) {
setSuggestionSource('clipboard')
setSuggestion(text)
}
}
check()
window.addEventListener('focus', check)
@@ -364,6 +370,17 @@ export function DownloadBar(): React.JSX.Element {
}
}, [])
// A link handed in from outside always takes priority over whatever the
// clipboard banner was showing — it's a direct request, not a guess.
useEffect(
() =>
window.api.onExternalUrl((incomingUrl) => {
setSuggestionSource('external')
setSuggestion(incomingUrl)
}),
[]
)
function acceptSuggestion(): void {
if (!suggestion) return
lastSeen.current = suggestion
@@ -595,7 +612,10 @@ export function DownloadBar(): React.JSX.Element {
{suggestion && (
<div className={styles.suggestion}>
<LinkRegular />
<Caption1 className={styles.suggestionText}>Use copied link? {suggestion}</Caption1>
<Caption1 className={styles.suggestionText}>
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
{suggestion}
</Caption1>
<Button size="small" appearance="primary" onClick={acceptSuggestion}>
Use
</Button>
+154
View File
@@ -0,0 +1,154 @@
import {
Card,
Title2,
Body1,
Caption1,
Field,
Input,
Button,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
FolderRegular,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
RocketRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
const useStyles = makeStyles({
root: {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflowY: 'auto',
padding: '24px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
padding: '32px',
maxWidth: '460px',
width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
brandRow: {
display: 'flex',
alignItems: 'center',
gap: '14px'
},
mark: {
width: '48px',
height: '48px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '26px'
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
tips: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
tip: {
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
},
tipIcon: {
fontSize: '18px',
flexShrink: 0,
marginTop: '2px',
color: tokens.colorCompoundBrandForeground1
}
})
const TIPS: { icon: React.JSX.Element; text: string }[] = [
{
icon: <ClipboardPasteRegular />,
text: 'Copy a video link and AeroFetch offers to fetch it as soon as you switch back.'
},
{
icon: <HistoryRegular />,
text: 'Downloads queue with a concurrency cap, and every completed file lands in History.'
},
{
icon: <OptionsRegular />,
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
}
]
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const update = useSettings((s) => s.update)
return (
<div className={styles.root}>
<Card className={styles.card}>
<div className={styles.brandRow}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2>Welcome to AeroFetch</Title2>
</div>
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
</Field>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
appearance="primary"
icon={<RocketRegular />}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</Card>
</div>
)
}
+88 -2
View File
@@ -11,6 +11,7 @@ import {
Spinner,
Text,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -28,7 +29,9 @@ import {
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
@@ -40,15 +43,19 @@ import {
type CookieBrowser,
type CookiesStatus,
type BackupExportResult,
type BackupImportResult
type BackupImportResult,
type ThemeMode,
type AccentColor
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { useSystemTheme } from '../store/systemTheme'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { useTemplates } from '../store/templates'
import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
import { ACCENT_OPTIONS } from '../theme'
const useStyles = makeStyles({
root: {
@@ -84,6 +91,23 @@ const useStyles = makeStyles({
hint: {
color: tokens.colorNeutralForeground3
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius('50%'),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
},
swatchActive: {
...shorthands.borderColor(tokens.colorNeutralForeground1)
},
errorList: {
display: 'flex',
flexDirection: 'column',
@@ -121,6 +145,12 @@ const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
@@ -147,6 +177,9 @@ export function SettingsView(): React.JSX.Element {
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const proxy = useSettings((s) => s.proxy)
@@ -360,6 +393,59 @@ export function SettingsView(): React.JSX.Element {
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-pressed={accentColor === opt.value}
aria-label={opt.label}
title={opt.label}
/>
))}
</div>
</Field>
<Caption1 className={styles.hint}>
{highContrast
? '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 && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings
</Button>
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
+5 -5
View File
@@ -122,6 +122,8 @@ interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
followingSystem: boolean
onToggleTheme: () => void
}
@@ -129,6 +131,7 @@ export function Sidebar({
tab,
onTabChange,
isDark,
followingSystem,
onToggleTheme
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
@@ -154,10 +157,7 @@ export function Sidebar({
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
style={
active
? {
backgroundColor: isDark ? '#3b2f24' : '#efe5df',
boxShadow: `inset 3px 0 0 0 ${isDark ? '#c7ac9e' : '#825e4a'}`
}
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
onClick={() => onTabChange(n.value)}
@@ -175,7 +175,7 @@ export function Sidebar({
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{isDark ? 'Dark' : 'Light'}
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
</div>
+8 -2
View File
@@ -18,6 +18,7 @@ if (import.meta.env.DEV && !window.api) {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'toffee',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
@@ -29,7 +30,8 @@ if (import.meta.env.DEV && !window.api) {
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
notifyOnComplete: true,
hasCompletedOnboarding: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
@@ -133,7 +135,11 @@ if (import.meta.env.DEV && !window.api) {
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {}
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {}
}
}
+5 -1
View File
@@ -12,6 +12,7 @@ const FALLBACK: Settings = {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'toffee',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
@@ -23,7 +24,10 @@ const FALLBACK: Settings = {
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
notifyOnComplete: true,
// True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW
}
interface SettingsState extends Settings {
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand'
import type { SystemThemeInfo } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
const prefersDark =
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)')
: null
export const useSystemTheme = create<SystemThemeInfo>(() => ({
shouldUseDarkColors: prefersDark?.matches ?? false,
shouldUseHighContrastColors: false
}))
if (!PREVIEW) {
window.api.getSystemTheme().then((info) => useSystemTheme.setState(info))
window.api.onSystemThemeUpdate((info) => useSystemTheme.setState(info))
} else {
// Browser preview has no nativeTheme bridge — approximate dark-mode
// follow with matchMedia so flipping the OS/devtools preference still works.
prefersDark?.addEventListener('change', (e) =>
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
)
}
+140 -10
View File
@@ -4,11 +4,12 @@ import {
type BrandVariants,
type Theme
} from '@fluentui/react-components'
import type { AccentColor } from '@shared/ipc'
// Toffee-brown brand ramp. The product palette (50950) is remapped onto
// Fluent's 16-stop BrandVariants (10 = darkest … 160 = lightest) so the LIGHT
// primary lands on toffee-600 (#825e4a) at index 80, and the DARK primary on a
// lightened toffee-400 (#b5917d) — applied via darkBrandOverrides below.
// lightened toffee-400 (#b5917d) — applied via toffeeDarkOverrides below.
const toffee: BrandVariants = {
10: '#17100d',
20: '#201713',
@@ -28,6 +29,67 @@ const toffee: BrandVariants = {
160: '#f6f1ef'
}
// Additional accent presets (Phase E). Each ramp reuses toffee's exact
// lightness curve at a different hue, so they read as siblings rather than
// arbitrary colors — only `buildDarkOverrides` below differs from toffee's
// hand-tuned dark lift.
const slate: BrandVariants = {
10: '#0f1215',
20: '#15191e',
30: '#1e242a',
40: '#2b323b',
50: '#343d48',
60: '#404c59',
70: '#4b5968',
80: '#566576',
90: '#617387',
100: '#6b7e94',
110: '#8998a9',
120: '#a6b2bf',
130: '#b7c0cb',
140: '#c4cbd4',
150: '#e1e5ea',
160: '#f1f2f5'
}
const evergreen: BrandVariants = {
10: '#0d1712',
20: '#12211a',
30: '#1a2e25',
40: '#254134',
50: '#2d4f3f',
60: '#37624e',
70: '#40735b',
80: '#498368',
90: '#549576',
100: '#5ca382',
110: '#7cb69b',
120: '#9dc8b4',
130: '#afd2c2',
140: '#bedacd',
150: '#deede6',
160: '#eff6f3'
}
const lavender: BrandVariants = {
10: '#120e16',
20: '#191320',
30: '#231b2d',
40: '#32273f',
50: '#3d2f4d',
60: '#4b3a5f',
70: '#58446f',
80: '#644e7e',
90: '#725890',
100: '#7d619e',
110: '#9781b1',
120: '#b1a0c5',
130: '#c0b2d0',
140: '#cbc0d8',
150: '#e5dfec',
160: '#f2f0f6'
}
// Rounder corners — buttons/inputs ~10px, cards ~16px. NOT pills.
const friendlyRadii = {
borderRadiusSmall: '5px',
@@ -36,10 +98,13 @@ const friendlyRadii = {
borderRadiusXLarge: '16px'
} as const
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses toffee only as
// the accent. The default dark brand is too low-contrast on near-black, so lift
// the brand to toffee-400 and flip on-brand text to dark for legible buttons.
const darkBrandOverrides = {
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses the accent
// ramp only for buttons/links. The default dark brand is too low-contrast on
// near-black, so lift the brand to ramp-110 and flip on-brand text to dark for
// legible buttons. Toffee's lift was hand-tuned (hover/pressed/stroke shades
// don't land exactly on ramp stops); newer presets use buildDarkOverrides,
// which derives the same shape directly from each ramp's own stops.
const toffeeDarkOverrides = {
colorBrandBackground: '#b5917d',
colorBrandBackgroundHover: '#c19f8c',
colorBrandBackgroundPressed: '#a2755d',
@@ -62,19 +127,84 @@ const darkBrandOverrides = {
colorBrandStroke2: '#5f4636'
} as const
export const lightTheme: Theme = { ...createLightTheme(toffee), ...friendlyRadii }
export const darkTheme: Theme = { ...createDarkTheme(toffee), ...friendlyRadii, ...darkBrandOverrides }
function buildDarkOverrides(ramp: BrandVariants): Record<string, string> {
return {
colorBrandBackground: ramp[110],
colorBrandBackgroundHover: ramp[120],
colorBrandBackgroundPressed: ramp[100],
colorBrandBackgroundSelected: ramp[110],
colorNeutralForegroundOnBrand: ramp[10],
colorCompoundBrandBackground: ramp[110],
colorCompoundBrandBackgroundHover: ramp[120],
colorCompoundBrandBackgroundPressed: ramp[100],
colorCompoundBrandForeground1: ramp[120],
colorCompoundBrandForeground1Hover: ramp[130],
colorCompoundBrandForeground1Pressed: ramp[110],
colorCompoundBrandStroke: ramp[110],
colorCompoundBrandStrokeHover: ramp[120],
colorBrandForeground1: ramp[120],
colorBrandForeground2: ramp[130],
colorBrandForegroundLink: ramp[120],
colorBrandForegroundLinkHover: ramp[130],
colorBrandForegroundLinkPressed: ramp[110],
colorBrandStroke1: ramp[110],
colorBrandStroke2: ramp[60]
}
}
interface AccentPreset {
label: string
light: Theme
dark: Theme
/** ramp-80 — the light-mode primary, used as a swatch dot in the accent picker */
swatch: string
}
function buildPreset(
label: string,
ramp: BrandVariants,
darkOverrides: Record<string, string>
): AccentPreset {
return {
label,
light: { ...createLightTheme(ramp), ...friendlyRadii },
dark: { ...createDarkTheme(ramp), ...friendlyRadii, ...darkOverrides },
swatch: ramp[80]
}
}
export const ACCENT_PRESETS: Record<AccentColor, AccentPreset> = {
toffee: buildPreset('Toffee', toffee, toffeeDarkOverrides),
slate: buildPreset('Slate', slate, buildDarkOverrides(slate)),
evergreen: buildPreset('Evergreen', evergreen, buildDarkOverrides(evergreen)),
lavender: buildPreset('Lavender', lavender, buildDarkOverrides(lavender))
}
export const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = (
Object.keys(ACCENT_PRESETS) as AccentColor[]
).map((value) => ({
value,
label: ACCENT_PRESETS[value].label,
swatch: ACCENT_PRESETS[value].swatch
}))
export function getTheme(accent: AccentColor, mode: 'light' | 'dark'): Theme {
const preset = ACCENT_PRESETS[accent] ?? ACCENT_PRESETS.toffee
return mode === 'dark' ? preset.dark : preset.light
}
// Page background behind the app shell. Light: warm toffee paper. Dark: neutral
// charcoal (NOT brown) — toffee shows up only on accents.
// charcoal (NOT brown) — the accent shows up only on buttons/links, independent
// of which preset is chosen.
export const pageBackground = {
light: '#f6f1ef',
dark: '#161618'
}
// Per-kind thumbnail tints. Video vs audio differ by ramp DEPTH rather than a
// competing hue, keeping the monochrome-warm Studio look. Theme-aware because
// these sit on the neutral card surface.
// competing hue, keeping the monochrome-warm Studio look. Theme-aware (light/
// dark) but intentionally NOT accent-aware — these sit on the neutral card
// surface and shouldn't shift hue when the user picks a different accent.
export const thumbColors = {
light: {
video: { bg: '#ece3df', fg: '#825e4a' },
+29 -3
View File
@@ -33,7 +33,14 @@ export const IpcChannels = {
errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear',
backupExport: 'backup:export',
backupImport: 'backup:import'
backupImport: 'backup:import',
systemThemeGet: 'system-theme:get',
/** main → renderer push channel for OS theme/contrast changes (nativeTheme 'updated') */
systemThemeUpdate: 'system-theme:update',
openHighContrastSettings: 'shell:open-high-contrast-settings',
/** main renderer push channel: a URL handed to AeroFetch from outside the app
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
externalUrl: 'external-url'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -69,6 +76,21 @@ export type CookieSource = 'none' | 'browser' | 'login'
export const COOKIE_BROWSERS = ['chrome', 'edge', 'firefox', 'brave', 'opera', 'vivaldi'] as const
export type CookieBrowser = (typeof COOKIE_BROWSERS)[number]
/** Accent color presets for the brand ramp (see src/renderer/src/theme.ts). */
export const ACCENT_COLORS = ['toffee', 'slate', 'evergreen', 'lavender'] as const
export type AccentColor = (typeof ACCENT_COLORS)[number]
/** UI color theme: an explicit mode, or 'system' to follow the OS preference. */
export type ThemeMode = 'light' | 'dark' | 'system'
/** OS-level theme signal, read from Electron's nativeTheme in the main process. */
export interface SystemThemeInfo {
/** whether Windows is currently in dark mode */
shouldUseDarkColors: boolean
/** whether a Windows high-contrast theme is active */
shouldUseHighContrastColors: boolean
}
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
export const SPONSORBLOCK_CATEGORIES = [
'sponsor',
@@ -270,8 +292,10 @@ export interface Settings {
maxConcurrent: number
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
filenameTemplate: string
/** UI color theme */
theme: 'light' | 'dark'
/** UI color theme: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** brand accent preset (buttons, links, selected nav item) */
accentColor: AccentColor
/** auto-detect video links from the clipboard on window focus */
clipboardWatch: boolean
/** default post-processing / format options for new downloads */
@@ -296,6 +320,8 @@ export interface Settings {
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
/** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean
}
/**
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { extractIncomingUrl } from '../src/main/deeplink'
const TARGET = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
let dir: string | null = null
function urlFile(name: string, contents: string): string {
dir ??= mkdtempSync(join(tmpdir(), 'aerofetch-deeplink-'))
const path = join(dir, name)
writeFileSync(path, contents)
return path
}
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true })
dir = null
})
describe('extractIncomingUrl — aerofetch:// protocol', () => {
it('extracts the url= query param', () => {
const arg = `aerofetch://download?url=${encodeURIComponent(TARGET)}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBe(TARGET)
})
it('finds the protocol arg regardless of its position in argv', () => {
const arg = `aerofetch://download?url=${encodeURIComponent(TARGET)}`
expect(extractIncomingUrl(['AeroFetch.exe', '--some-flag', arg])).toBe(TARGET)
})
it('rejects a non-http(s) target (defence in depth)', () => {
const arg = `aerofetch://download?url=${encodeURIComponent('file:///C:/secrets.txt')}`
expect(extractIncomingUrl(['AeroFetch.exe', arg])).toBeNull()
})
it('ignores a malformed protocol invocation', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://'])).toBeNull()
})
it('returns null when there is no url= param', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'aerofetch://download'])).toBeNull()
})
})
describe('extractIncomingUrl — .url Internet Shortcut (Explorer "Send to")', () => {
it('reads the URL= line from a real .url file', () => {
const path = urlFile('Video.url', `[InternetShortcut]\r\nURL=${TARGET}\r\n`)
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBe(TARGET)
})
it('rejects a .url file whose target is not http(s)', () => {
const path = urlFile('Local.url', '[InternetShortcut]\r\nURL=file:///C:/secrets.txt\r\n')
expect(extractIncomingUrl(['AeroFetch.exe', path])).toBeNull()
})
it('returns null for a .url path that does not exist on disk', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'C:\\nope\\Missing.url'])).toBeNull()
})
it('ignores files that merely end in .url-like text but are not real paths', () => {
expect(extractIncomingUrl(['AeroFetch.exe', 'not-a-real-path.url'])).toBeNull()
})
})
describe('extractIncomingUrl — no match', () => {
it('returns null for ordinary argv (e.g. plain launch)', () => {
expect(extractIncomingUrl(['AeroFetch.exe'])).toBeNull()
expect(extractIncomingUrl([])).toBeNull()
})
})