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.
|
Some items are Android-specific in Seal and adapted to Windows here.
|
||||||
|
|
||||||
- [ ] **Theme presets + "follow system"** and high-contrast (toffee light/dark already exists;
|
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
|
||||||
add system-follow + a few accent presets). *Material-You dynamic color is Android-only;
|
Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee
|
||||||
closest Windows analog is accent presets / system accent.*
|
(original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing
|
||||||
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional.
|
toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
|
||||||
- [ ] **Windows "open with" / share integration** — register a URL-protocol handler or Explorer
|
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
|
||||||
"Send to" entry (analog of Android's share sheet). *Lower priority, platform-specific.*
|
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
|
||||||
- [ ] **Onboarding / welcome screen** on first run.
|
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)
|
## Not portable from Seal (noted for completeness)
|
||||||
|
|
||||||
- SAF directory picker (Android storage) → already covered by the native folder dialog.
|
- 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).
|
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ appId: com.aerofetch.app
|
|||||||
productName: AeroFetch
|
productName: AeroFetch
|
||||||
copyright: yt-dlp frontend
|
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:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
output: dist
|
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()
|
||||||
|
}
|
||||||
+103
-17
@@ -1,5 +1,5 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
||||||
import { join } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type CommandTemplate,
|
type CommandTemplate,
|
||||||
type YtdlpUpdateChannel
|
type YtdlpUpdateChannel,
|
||||||
|
type SystemThemeInfo
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
@@ -20,6 +21,13 @@ import { safeOpenPath, safeShowInFolder } from './reveal'
|
|||||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||||
import { exportBackup, importBackup } from './backup'
|
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
|
// 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 —
|
// 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.
|
// before the app is ready and before any user path is read.
|
||||||
setupPortableData()
|
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
|
// Page background per theme — keep in sync with `pageBackground` in
|
||||||
// src/renderer/src/theme.ts. Set as the window's NATIVE background so the
|
// 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
|
// 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.
|
// Windows) shows the app's current color instead of a mismatched white flash.
|
||||||
const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const
|
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 {
|
function createWindow(): void {
|
||||||
const mainWindow = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 920,
|
width: 920,
|
||||||
height: 700,
|
height: 700,
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: THEME_BACKGROUND[getSettings().theme],
|
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.cjs'),
|
preload: join(__dirname, '../preload/index.cjs'),
|
||||||
@@ -58,14 +93,29 @@ function createWindow(): void {
|
|||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
mainWindow = win
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
win.on('ready-to-show', () => {
|
||||||
mainWindow.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
|
// 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.
|
// file:// or custom-protocol URL can't be used to launch a local handler.
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
win.webContents.setWindowOpenHandler((details) => {
|
||||||
try {
|
try {
|
||||||
const { protocol } = new URL(details.url)
|
const { protocol } = new URL(details.url)
|
||||||
if (protocol === 'http:' || protocol === 'https:') shell.openExternal(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
|
// The app shell is local and self-contained — never let the renderer navigate
|
||||||
// away from it (defence in depth; HMR uses websockets, not navigation).
|
// 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']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} 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
|
// repaint never flashes a mismatched color behind an overlay. Use the
|
||||||
// validated result, not the raw partial (which may hold a bogus value).
|
// validated result, not the raw partial (which may hold a bogus value).
|
||||||
if (partial.theme) {
|
if (partial.theme) {
|
||||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme])
|
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
||||||
|
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return result
|
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.historyList, () => listHistory())
|
||||||
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
||||||
@@ -166,12 +224,37 @@ function registerIpcHandlers(): void {
|
|||||||
const result = await importBackup(win)
|
const result = await importBackup(win)
|
||||||
// A restored backup may have changed the theme; keep the native window
|
// A restored backup may have changed the theme; keep the native window
|
||||||
// background in sync the same way settingsSet does.
|
// 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
|
return result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
@@ -179,15 +262,18 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
registerSystemThemeBridge()
|
||||||
|
registerSendToShortcut()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
+11
-2
@@ -7,6 +7,7 @@ import {
|
|||||||
VIDEO_CODECS,
|
VIDEO_CODECS,
|
||||||
SPONSORBLOCK_CATEGORIES,
|
SPONSORBLOCK_CATEGORIES,
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
|
ACCENT_COLORS,
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
type Settings,
|
type Settings,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
@@ -21,6 +22,7 @@ const DEFAULTS: Settings = {
|
|||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
|
accentColor: 'toffee',
|
||||||
clipboardWatch: true,
|
clipboardWatch: true,
|
||||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
proxy: '',
|
proxy: '',
|
||||||
@@ -32,7 +34,8 @@ const DEFAULTS: Settings = {
|
|||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true
|
notifyOnComplete: true,
|
||||||
|
hasCompletedOnboarding: false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
/** 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
|
if (value === undefined) continue
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'theme':
|
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
|
break
|
||||||
case 'defaultKind':
|
case 'defaultKind':
|
||||||
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
|
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
|
||||||
@@ -120,6 +128,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'downloadArchive':
|
case 'downloadArchive':
|
||||||
case 'customCommandEnabled':
|
case 'customCommandEnabled':
|
||||||
case 'notifyOnComplete':
|
case 'notifyOnComplete':
|
||||||
|
case 'hasCompletedOnboarding':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
case 'defaultTemplateId':
|
case 'defaultTemplateId':
|
||||||
|
|||||||
+23
-1
@@ -16,7 +16,8 @@ import {
|
|||||||
type CommandPreviewResult,
|
type CommandPreviewResult,
|
||||||
type ErrorLogEntry,
|
type ErrorLogEntry,
|
||||||
type BackupExportResult,
|
type BackupExportResult,
|
||||||
type BackupImportResult
|
type BackupImportResult,
|
||||||
|
type SystemThemeInfo
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// The surface exposed to the renderer. Keep this thin: it only forwards to 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)
|
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
|
||||||
ipcRenderer.on(IpcChannels.downloadEvent, listener)
|
ipcRenderer.on(IpcChannels.downloadEvent, listener)
|
||||||
return () => ipcRenderer.removeListener(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
|
|||||||
import { DownloadsView } from './components/DownloadsView'
|
import { DownloadsView } from './components/DownloadsView'
|
||||||
import { HistoryView } from './components/HistoryView'
|
import { HistoryView } from './components/HistoryView'
|
||||||
import { SettingsView } from './components/SettingsView'
|
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 { useSettings } from './store/settings'
|
||||||
|
import { useSystemTheme } from './store/systemTheme'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
provider: {
|
provider: {
|
||||||
@@ -27,12 +29,22 @@ const useStyles = makeStyles({
|
|||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const theme = useSettings((s) => s.theme)
|
const theme = useSettings((s) => s.theme)
|
||||||
|
const accentColor = useSettings((s) => s.accentColor)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
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')
|
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 (
|
return (
|
||||||
<FluentProvider theme={isDark ? darkTheme : lightTheme} className={styles.provider}>
|
<FluentProvider
|
||||||
|
theme={getTheme(accentColor, isDark ? 'dark' : 'light')}
|
||||||
|
className={styles.provider}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.root}
|
className={styles.root}
|
||||||
style={{
|
style={{
|
||||||
@@ -40,10 +52,15 @@ function App(): React.JSX.Element {
|
|||||||
colorScheme: isDark ? 'dark' : 'light'
|
colorScheme: isDark ? 'dark' : 'light'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showOnboarding ? (
|
||||||
|
<Onboarding />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
tab={tab}
|
tab={tab}
|
||||||
onTabChange={setTab}
|
onTabChange={setTab}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
|
followingSystem={theme === 'system'}
|
||||||
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -52,6 +69,8 @@ function App(): React.JSX.Element {
|
|||||||
{tab === 'history' && <HistoryView />}
|
{tab === 'history' && <HistoryView />}
|
||||||
{tab === 'settings' && <SettingsView />}
|
{tab === 'settings' && <SettingsView />}
|
||||||
</main>
|
</main>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -337,7 +337,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
|
|
||||||
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
|
// 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).
|
// 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 [suggestion, setSuggestion] = useState<string | null>(null)
|
||||||
|
const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard')
|
||||||
const urlRef = useRef('')
|
const urlRef = useRef('')
|
||||||
urlRef.current = url
|
urlRef.current = url
|
||||||
const lastSeen = useRef<string | null>(null)
|
const lastSeen = useRef<string | null>(null)
|
||||||
@@ -354,7 +357,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
if (!active) return
|
if (!active) return
|
||||||
text = text.trim()
|
text = text.trim()
|
||||||
if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text)
|
if (looksLikeUrl(text) && text !== lastSeen.current) {
|
||||||
|
setSuggestionSource('clipboard')
|
||||||
|
setSuggestion(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
check()
|
check()
|
||||||
window.addEventListener('focus', 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 {
|
function acceptSuggestion(): void {
|
||||||
if (!suggestion) return
|
if (!suggestion) return
|
||||||
lastSeen.current = suggestion
|
lastSeen.current = suggestion
|
||||||
@@ -595,7 +612,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
{suggestion && (
|
{suggestion && (
|
||||||
<div className={styles.suggestion}>
|
<div className={styles.suggestion}>
|
||||||
<LinkRegular />
|
<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}>
|
<Button size="small" appearance="primary" onClick={acceptSuggestion}>
|
||||||
Use
|
Use
|
||||||
</Button>
|
</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,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
@@ -28,7 +29,9 @@ import {
|
|||||||
DocumentArrowUpRegular,
|
DocumentArrowUpRegular,
|
||||||
BugRegular,
|
BugRegular,
|
||||||
CopyRegular,
|
CopyRegular,
|
||||||
DeleteRegular
|
DeleteRegular,
|
||||||
|
PaintBucketRegular,
|
||||||
|
AccessibilityRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import {
|
import {
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
@@ -40,15 +43,19 @@ import {
|
|||||||
type CookieBrowser,
|
type CookieBrowser,
|
||||||
type CookiesStatus,
|
type CookiesStatus,
|
||||||
type BackupExportResult,
|
type BackupExportResult,
|
||||||
type BackupImportResult
|
type BackupImportResult,
|
||||||
|
type ThemeMode,
|
||||||
|
type AccentColor
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useSystemTheme } from '../store/systemTheme'
|
||||||
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
||||||
import { useTemplates } from '../store/templates'
|
import { useTemplates } from '../store/templates'
|
||||||
import { useErrorLog } from '../store/errorlog'
|
import { useErrorLog } from '../store/errorlog'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||||
import { TemplateManager } from './TemplateManager'
|
import { TemplateManager } from './TemplateManager'
|
||||||
|
import { ACCENT_OPTIONS } from '../theme'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -84,6 +91,23 @@ const useStyles = makeStyles({
|
|||||||
hint: {
|
hint: {
|
||||||
color: tokens.colorNeutralForeground3
|
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: {
|
errorList: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -121,6 +145,12 @@ const FORMAT_OPTIONS = [
|
|||||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
|
...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 = [
|
const COOKIE_SOURCE_OPTIONS = [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
{ value: 'browser', label: "From a browser's cookie store" },
|
{ 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 defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
const maxConcurrent = useSettings((s) => s.maxConcurrent)
|
const maxConcurrent = useSettings((s) => s.maxConcurrent)
|
||||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
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 clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||||||
const proxy = useSettings((s) => s.proxy)
|
const proxy = useSettings((s) => s.proxy)
|
||||||
@@ -360,6 +393,59 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
</Field>
|
</Field>
|
||||||
</Card>
|
</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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<OptionsRegular className={styles.sectionIcon} />
|
<OptionsRegular className={styles.sectionIcon} />
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ interface SidebarProps {
|
|||||||
tab: TabValue
|
tab: TabValue
|
||||||
onTabChange: (t: TabValue) => void
|
onTabChange: (t: TabValue) => void
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
|
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
|
||||||
|
followingSystem: boolean
|
||||||
onToggleTheme: () => void
|
onToggleTheme: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ export function Sidebar({
|
|||||||
tab,
|
tab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
isDark,
|
isDark,
|
||||||
|
followingSystem,
|
||||||
onToggleTheme
|
onToggleTheme
|
||||||
}: SidebarProps): React.JSX.Element {
|
}: SidebarProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
@@ -154,10 +157,7 @@ export function Sidebar({
|
|||||||
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
||||||
style={
|
style={
|
||||||
active
|
active
|
||||||
? {
|
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||||
backgroundColor: isDark ? '#3b2f24' : '#efe5df',
|
|
||||||
boxShadow: `inset 3px 0 0 0 ${isDark ? '#c7ac9e' : '#825e4a'}`
|
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => onTabChange(n.value)}
|
onClick={() => onTabChange(n.value)}
|
||||||
@@ -175,7 +175,7 @@ export function Sidebar({
|
|||||||
<div className={styles.themeRow}>
|
<div className={styles.themeRow}>
|
||||||
<span className={styles.themeLabel}>
|
<span className={styles.themeLabel}>
|
||||||
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
||||||
{isDark ? 'Dark' : 'Light'}
|
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
|
||||||
</span>
|
</span>
|
||||||
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
|
accentColor: 'toffee',
|
||||||
clipboardWatch: true,
|
clipboardWatch: true,
|
||||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
proxy: '',
|
proxy: '',
|
||||||
@@ -29,7 +30,8 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
defaultTemplateId: null,
|
||||||
notifyOnComplete: true
|
notifyOnComplete: true,
|
||||||
|
hasCompletedOnboarding: true
|
||||||
}
|
}
|
||||||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
// 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.
|
// sign-in/clear flow be exercised in this browser-only preview.
|
||||||
@@ -133,7 +135,11 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
clearErrorLog: async () => [],
|
clearErrorLog: async () => [],
|
||||||
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
||||||
importBackup: async () => ({ ok: true }),
|
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,
|
maxConcurrent: 2,
|
||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
|
accentColor: 'toffee',
|
||||||
clipboardWatch: true,
|
clipboardWatch: true,
|
||||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
proxy: '',
|
proxy: '',
|
||||||
@@ -23,7 +24,10 @@ const FALLBACK: Settings = {
|
|||||||
downloadArchive: false,
|
downloadArchive: false,
|
||||||
customCommandEnabled: false,
|
customCommandEnabled: false,
|
||||||
defaultTemplateId: null,
|
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 {
|
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 BrandVariants,
|
||||||
type Theme
|
type Theme
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
|
import type { AccentColor } from '@shared/ipc'
|
||||||
|
|
||||||
// Toffee-brown brand ramp. The product palette (50–950) is remapped onto
|
// Toffee-brown brand ramp. The product palette (50–950) is remapped onto
|
||||||
// Fluent's 16-stop BrandVariants (10 = darkest … 160 = lightest) so the LIGHT
|
// 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
|
// 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 = {
|
const toffee: BrandVariants = {
|
||||||
10: '#17100d',
|
10: '#17100d',
|
||||||
20: '#201713',
|
20: '#201713',
|
||||||
@@ -28,6 +29,67 @@ const toffee: BrandVariants = {
|
|||||||
160: '#f6f1ef'
|
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.
|
// Rounder corners — buttons/inputs ~10px, cards ~16px. NOT pills.
|
||||||
const friendlyRadii = {
|
const friendlyRadii = {
|
||||||
borderRadiusSmall: '5px',
|
borderRadiusSmall: '5px',
|
||||||
@@ -36,10 +98,13 @@ const friendlyRadii = {
|
|||||||
borderRadiusXLarge: '16px'
|
borderRadiusXLarge: '16px'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses toffee only as
|
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses the accent
|
||||||
// the accent. The default dark brand is too low-contrast on near-black, so lift
|
// ramp only for buttons/links. The default dark brand is too low-contrast on
|
||||||
// the brand to toffee-400 and flip on-brand text to dark for legible buttons.
|
// near-black, so lift the brand to ramp-110 and flip on-brand text to dark for
|
||||||
const darkBrandOverrides = {
|
// 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',
|
colorBrandBackground: '#b5917d',
|
||||||
colorBrandBackgroundHover: '#c19f8c',
|
colorBrandBackgroundHover: '#c19f8c',
|
||||||
colorBrandBackgroundPressed: '#a2755d',
|
colorBrandBackgroundPressed: '#a2755d',
|
||||||
@@ -62,19 +127,84 @@ const darkBrandOverrides = {
|
|||||||
colorBrandStroke2: '#5f4636'
|
colorBrandStroke2: '#5f4636'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const lightTheme: Theme = { ...createLightTheme(toffee), ...friendlyRadii }
|
function buildDarkOverrides(ramp: BrandVariants): Record<string, string> {
|
||||||
export const darkTheme: Theme = { ...createDarkTheme(toffee), ...friendlyRadii, ...darkBrandOverrides }
|
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
|
// 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 = {
|
export const pageBackground = {
|
||||||
light: '#f6f1ef',
|
light: '#f6f1ef',
|
||||||
dark: '#161618'
|
dark: '#161618'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-kind thumbnail tints. Video vs audio differ by ramp DEPTH rather than a
|
// 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
|
// competing hue, keeping the monochrome-warm Studio look. Theme-aware (light/
|
||||||
// these sit on the neutral card surface.
|
// 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 = {
|
export const thumbColors = {
|
||||||
light: {
|
light: {
|
||||||
video: { bg: '#ece3df', fg: '#825e4a' },
|
video: { bg: '#ece3df', fg: '#825e4a' },
|
||||||
|
|||||||
+29
-3
@@ -33,7 +33,14 @@ export const IpcChannels = {
|
|||||||
errorLogList: 'errorlog:list',
|
errorLogList: 'errorlog:list',
|
||||||
errorLogClear: 'errorlog:clear',
|
errorLogClear: 'errorlog:clear',
|
||||||
backupExport: 'backup:export',
|
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
|
} as const
|
||||||
|
|
||||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
/** 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 const COOKIE_BROWSERS = ['chrome', 'edge', 'firefox', 'brave', 'opera', 'vivaldi'] as const
|
||||||
export type CookieBrowser = (typeof COOKIE_BROWSERS)[number]
|
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). */
|
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
|
||||||
export const SPONSORBLOCK_CATEGORIES = [
|
export const SPONSORBLOCK_CATEGORIES = [
|
||||||
'sponsor',
|
'sponsor',
|
||||||
@@ -270,8 +292,10 @@ export interface Settings {
|
|||||||
maxConcurrent: number
|
maxConcurrent: number
|
||||||
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
|
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
|
||||||
filenameTemplate: string
|
filenameTemplate: string
|
||||||
/** UI color theme */
|
/** UI color theme: explicit light/dark, or 'system' to follow the OS */
|
||||||
theme: 'light' | 'dark'
|
theme: ThemeMode
|
||||||
|
/** brand accent preset (buttons, links, selected nav item) */
|
||||||
|
accentColor: AccentColor
|
||||||
/** auto-detect video links from the clipboard on window focus */
|
/** auto-detect video links from the clipboard on window focus */
|
||||||
clipboardWatch: boolean
|
clipboardWatch: boolean
|
||||||
/** default post-processing / format options for new downloads */
|
/** default post-processing / format options for new downloads */
|
||||||
@@ -296,6 +320,8 @@ export interface Settings {
|
|||||||
defaultTemplateId: string | null
|
defaultTemplateId: string | null
|
||||||
/** show a native OS notification when a download finishes or fails */
|
/** show a native OS notification when a download finishes or fails */
|
||||||
notifyOnComplete: boolean
|
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