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:
+60
-8
@@ -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 A–C'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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 (50–950) 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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user