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:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user