Background running for downloads/auto-download, library clipboard detect, updater auth (#7)
Co-authored-by: Wayne <bontragerl464@gmail.com> Co-committed-by: Wayne <bontragerl464@gmail.com>
This commit was merged in pull request #7.
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aerofetch",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fluentui/react-components": "^9.74.1",
|
||||
|
||||
@@ -43,6 +43,15 @@ interface ActiveDownload {
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
|
||||
/**
|
||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||
* spawned processes) when the user closes the window mid-download.
|
||||
*/
|
||||
export function hasActiveDownloads(): boolean {
|
||||
return active.size > 0
|
||||
}
|
||||
|
||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||
|
||||
function num(s?: string): number | undefined {
|
||||
|
||||
+47
-6
@@ -1,4 +1,4 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
|
||||
import { join, resolve } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import {
|
||||
@@ -15,9 +15,21 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
||||
import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||
import {
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
pauseDownload,
|
||||
previewCommand,
|
||||
hasActiveDownloads
|
||||
} from './download'
|
||||
import { runTerminal, cancelTerminal } from './terminal'
|
||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||
import {
|
||||
getSettings,
|
||||
setSettings,
|
||||
ensureMediaDirs,
|
||||
applyLaunchAtStartup,
|
||||
migrateSecretsAtRest
|
||||
} from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { setupPortableData } from './portable'
|
||||
@@ -95,6 +107,19 @@ function getSystemThemeInfo(): SystemThemeInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the user (once per run) that closing the window left AeroFetch running so
|
||||
// an in-progress download could finish — shown only when they haven't already
|
||||
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
||||
let notifiedBackground = false
|
||||
function notifyBackgroundOnce(): void {
|
||||
if (notifiedBackground || !Notification.isSupported()) return
|
||||
notifiedBackground = true
|
||||
new Notification({
|
||||
title: 'AeroFetch is still running',
|
||||
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
|
||||
}).show()
|
||||
}
|
||||
|
||||
// Web permissions a download manager never needs. They're denied for the app
|
||||
// window as defence-in-depth (audit T6): even if the renderer were compromised
|
||||
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
|
||||
@@ -137,12 +162,20 @@ function createWindow(): void {
|
||||
else win.show()
|
||||
})
|
||||
|
||||
// Minimize-to-tray: when enabled, closing the window hides it to the tray
|
||||
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
|
||||
// Closing the window hides to the tray (instead of quitting) when either the
|
||||
// user opted into background mode, OR a download is in flight — quitting would
|
||||
// kill the spawned yt-dlp processes and lose the download. A real quit (tray
|
||||
// menu / before-quit) sets isQuitting() so this lets the close through.
|
||||
win.on('close', (e) => {
|
||||
if (getSettings().minimizeToTray && !isQuitting()) {
|
||||
if (isQuitting()) return
|
||||
const downloadsRunning = hasActiveDownloads()
|
||||
if (getSettings().minimizeToTray || downloadsRunning) {
|
||||
e.preventDefault()
|
||||
win.hide()
|
||||
// If we're only staying alive because a download is running (the user
|
||||
// didn't opt into tray mode), tell them once — otherwise a window that
|
||||
// won't close looks like a bug.
|
||||
if (downloadsRunning && !getSettings().minimizeToTray) notifyBackgroundOnce()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -371,6 +404,14 @@ if (isPrimaryInstance) {
|
||||
// exist from first launch (downloads are routed into them by kind).
|
||||
ensureMediaDirs()
|
||||
|
||||
// Encrypt any credential still stored as legacy plaintext (from before at-rest
|
||||
// encryption), once safeStorage is available post-ready.
|
||||
migrateSecretsAtRest()
|
||||
|
||||
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
||||
// reflects the user's choice even if they changed it on another install.
|
||||
applyLaunchAtStartup(getSettings().launchAtStartup)
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
+104
-7
@@ -1,4 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import Store from 'electron-store'
|
||||
@@ -50,7 +50,23 @@ const DEFAULTS: Settings = {
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: false,
|
||||
minimizeToTray: false
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
updateToken: ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
|
||||
* startup and whenever the toggle changes. On Windows this writes/removes a
|
||||
* per-user registry Run entry — no admin needed, matching the app's no-elevation
|
||||
* stance. Best-effort: a failure here just means the toggle didn't take effect.
|
||||
*/
|
||||
export function applyLaunchAtStartup(enabled: boolean): void {
|
||||
try {
|
||||
app.setLoginItemSettings({ openAtLogin: enabled })
|
||||
} catch {
|
||||
/* non-fatal — e.g. unsupported platform */
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||
@@ -133,6 +149,72 @@ function getStore(): Store<Settings> {
|
||||
return store
|
||||
}
|
||||
|
||||
// --- Credential encryption at rest ------------------------------------------
|
||||
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
|
||||
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
|
||||
// so a leaked settings.json doesn't expose them. They're still decrypted before
|
||||
// reaching the renderer and exported in clear by backup — this guards the file at
|
||||
// rest only.
|
||||
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
|
||||
|
||||
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
|
||||
// legacy plaintext value (written before encryption existed, or while safeStorage
|
||||
// was unavailable) and migrate it on the next write.
|
||||
const ENC_PREFIX = 'enc:v1:'
|
||||
|
||||
/** Encrypt a secret for storage. Falls back to plaintext where safeStorage is unavailable. */
|
||||
function encryptSecret(plain: string): string {
|
||||
if (!plain) return ''
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return ENC_PREFIX + safeStorage.encryptString(plain).toString('base64')
|
||||
}
|
||||
} catch {
|
||||
/* fall through — store plaintext, as it was before encryption existed */
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
/** Decrypt a stored secret. Legacy plaintext is returned as-is; an undecryptable blob → ''. */
|
||||
function decryptSecret(stored: string): string {
|
||||
if (!stored.startsWith(ENC_PREFIX)) return stored
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
|
||||
} catch {
|
||||
// Different user/machine or a corrupt blob — drop it rather than surface
|
||||
// ciphertext into the UI or onto a yt-dlp command line.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** A copy of settings with the credential fields decrypted for in-process use. */
|
||||
function withDecryptedSecrets(raw: Settings): Settings {
|
||||
const out = { ...raw }
|
||||
for (const key of SECRET_KEYS) out[key] = decryptSecret(raw[key] ?? '')
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time (per launch) migration: re-store any credential still held as legacy
|
||||
* plaintext as ciphertext. Lets settings files written before at-rest encryption
|
||||
* get protected without a write on the hot getSettings() path. No-op when there's
|
||||
* nothing to migrate or safeStorage is unavailable.
|
||||
*/
|
||||
export function migrateSecretsAtRest(): void {
|
||||
let available = false
|
||||
try {
|
||||
available = safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!available) return
|
||||
const s = getStore()
|
||||
for (const key of SECRET_KEYS) {
|
||||
const raw = s.get(key) ?? ''
|
||||
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
|
||||
}
|
||||
}
|
||||
|
||||
export function getSettings(): Settings {
|
||||
const s = getStore()
|
||||
// getSettings() is on hot paths (buildCommand, notification checks, the system-
|
||||
@@ -154,7 +236,9 @@ export function getSettings(): Settings {
|
||||
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
|
||||
s.set('accentColor', DEFAULTS.accentColor)
|
||||
}
|
||||
return s.store
|
||||
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
|
||||
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
|
||||
return withDecryptedSecrets(s.store)
|
||||
}
|
||||
|
||||
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
|
||||
@@ -212,6 +296,12 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
case 'minimizeToTray':
|
||||
if (typeof value === 'boolean') s.set(key, value)
|
||||
break
|
||||
case 'launchAtStartup':
|
||||
if (typeof value === 'boolean') {
|
||||
s.set('launchAtStartup', value)
|
||||
applyLaunchAtStartup(value)
|
||||
}
|
||||
break
|
||||
case 'ytdlpChannel':
|
||||
// Same allowlist that guards the `--update-to` flag (audit F1).
|
||||
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
|
||||
@@ -254,14 +344,21 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
break
|
||||
case 'defaultVideoQuality':
|
||||
case 'defaultAudioQuality':
|
||||
// proxy may carry plaintext credentials (user:pass@host); they are stored
|
||||
// and exported by exportBackup as-is — documented, not masked.
|
||||
case 'proxy':
|
||||
case 'rateLimit':
|
||||
case 'youtubePlayerClient':
|
||||
case 'youtubePoToken':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
break
|
||||
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
|
||||
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
|
||||
// still writes them in clear (via the decrypted getSettings), as documented.
|
||||
case 'proxy':
|
||||
case 'youtubePoToken':
|
||||
if (typeof value === 'string') s.set(key, encryptSecret(value))
|
||||
break
|
||||
case 'updateToken':
|
||||
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
|
||||
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
|
||||
break
|
||||
}
|
||||
}
|
||||
return getSettings()
|
||||
|
||||
+14
-3
@@ -7,6 +7,16 @@
|
||||
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
|
||||
import { getAppIconPath } from './binaries'
|
||||
|
||||
// Fallback tray glyph (32×32 teal disc + white download arrow) used when no
|
||||
// build/icon.ico is present. Without this the tray is skipped (an empty image
|
||||
// makes an invisible/unclickable Windows tray entry), which would strand a
|
||||
// minimized-to-tray window with no way back. Embedded as base64 so it needs no
|
||||
// asset-bundling step.
|
||||
const FALLBACK_TRAY_PNG =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAl0lEQVR4nO3TQQ6AIAxEUU7giV17bd0i' +
|
||||
'aaDTmdqY0ISl/peirf11jvO6+/N5cHXKwlIIG6cQqngIoY5DiKy4C4G8aBwJohSArpIBmIgNKAWgDys' +
|
||||
'AL8QGlANmCHZc8dUW1HEYEEFA6/d+B6q4CVAhwnHkb2DiUwCDkMRRBHpccQsRnXB8RLCAULxHMAAqbm0j5b4VoPRg1jzWxpzrS/JA7QAAAABJRU5ErkJggg=='
|
||||
|
||||
let tray: Tray | null = null
|
||||
// True once the user has chosen to really quit (tray menu, or app.quit from the
|
||||
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
|
||||
@@ -31,9 +41,10 @@ function show(getWindow: () => BrowserWindow | null): void {
|
||||
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
|
||||
export function createTray(getWindow: () => BrowserWindow | null): void {
|
||||
if (tray) return
|
||||
const icon = nativeImage.createFromPath(getAppIconPath())
|
||||
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
|
||||
// worse than no tray — so skip it rather than ship a dead tray.
|
||||
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
|
||||
// ships, so minimize-to-tray always has a tray to restore from.
|
||||
let icon = nativeImage.createFromPath(getAppIconPath())
|
||||
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
||||
if (icon.isEmpty()) return
|
||||
|
||||
tray = new Tray(icon)
|
||||
|
||||
+24
-1
@@ -3,6 +3,7 @@ import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { stat, unlink } from 'fs/promises'
|
||||
import { join, normalize, dirname } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { getSettings } from './settings'
|
||||
import {
|
||||
IpcChannels,
|
||||
type AppUpdateInfo,
|
||||
@@ -10,6 +11,22 @@ import {
|
||||
type AppUpdateProgress
|
||||
} from '@shared/ipc'
|
||||
|
||||
/**
|
||||
* Authorization header for the update host. Empty unless the user has set an
|
||||
* updateToken in Settings — needed when the release repo is private or the Gitea
|
||||
* instance requires sign-in for anonymous access (the default on this instance).
|
||||
*
|
||||
* The token must never reach an origin other than the host-pinned UPDATE_HOST.
|
||||
* Every request that carries it guards against that on its own terms: the REST
|
||||
* check refuses to follow redirects (`redirect: 'error'`), and the download paths
|
||||
* use `redirect: 'manual'` and re-validate each hop with isTrustedDownloadUrl. So
|
||||
* a redirect can't bounce the header to another host on any path.
|
||||
*/
|
||||
function authHeader(): Record<string, string> {
|
||||
const tok = getSettings().updateToken?.trim()
|
||||
return tok ? { Authorization: `token ${tok}` } : {}
|
||||
}
|
||||
|
||||
// --- Update source -----------------------------------------------------------
|
||||
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
||||
// the repo's latest release over the public REST API and downloads the installer
|
||||
@@ -109,7 +126,11 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||
try {
|
||||
const res = await fetch(RELEASE_API, {
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { Accept: 'application/json', ...authHeader() },
|
||||
// The API lives at an exact pinned URL and answers 200 directly. Refusing
|
||||
// redirects keeps an authenticated check from ever forwarding the token to
|
||||
// another origin; a stray redirect fails safe into the catch below.
|
||||
redirect: 'error',
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -187,6 +208,7 @@ function fetchTrustedText(
|
||||
resolve(r)
|
||||
}
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
|
||||
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||
@@ -279,6 +301,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
||||
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||
const request = net.request({ url, redirect: 'manual' })
|
||||
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
|
||||
|
||||
const armIdle = (): void => {
|
||||
if (idle) clearTimeout(idle)
|
||||
|
||||
@@ -33,18 +33,7 @@ import { sameVideo } from '../store/queueStats'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||
function looksLikeUrl(text: string): boolean {
|
||||
const t = text.trim()
|
||||
if (!/^https?:\/\//i.test(t)) return false
|
||||
try {
|
||||
new URL(t)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
|
||||
|
||||
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||
function firstUrl(text: string): string | null {
|
||||
@@ -384,62 +373,22 @@ export function DownloadBar(): React.JSX.Element {
|
||||
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||
|
||||
// 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)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
async function check(): Promise<void> {
|
||||
if (!useSettings.getState().clipboardWatch || urlRef.current.trim()) return
|
||||
let text = ''
|
||||
try {
|
||||
text = (await window.api.readClipboard()) ?? ''
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!active) return
|
||||
text = text.trim()
|
||||
if (looksLikeUrl(text) && text !== lastSeen.current) {
|
||||
setSuggestionSource('clipboard')
|
||||
setSuggestion(text)
|
||||
}
|
||||
}
|
||||
check()
|
||||
window.addEventListener('focus', check)
|
||||
return () => {
|
||||
active = false
|
||||
window.removeEventListener('focus', check)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
// Clipboard auto-detect and links handed to AeroFetch from outside (the
|
||||
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
|
||||
// driven by the same hook the library's add-source field uses. An external link
|
||||
// always takes priority over a clipboard guess — it's a direct request.
|
||||
const {
|
||||
suggestion,
|
||||
source: suggestionSource,
|
||||
accept: acceptLink,
|
||||
dismiss: dismissSuggestion,
|
||||
offer: offerLink
|
||||
} = useClipboardLink(url)
|
||||
useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink])
|
||||
|
||||
function acceptSuggestion(): void {
|
||||
if (!suggestion) return
|
||||
lastSeen.current = suggestion
|
||||
onUrlChange(suggestion)
|
||||
setSuggestion(null)
|
||||
}
|
||||
|
||||
function dismissSuggestion(): void {
|
||||
lastSeen.current = suggestion
|
||||
setSuggestion(null)
|
||||
const link = acceptLink()
|
||||
if (link) onUrlChange(link)
|
||||
}
|
||||
|
||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||
|
||||
@@ -25,11 +25,14 @@ import {
|
||||
AppsListRegular,
|
||||
VideoClipMultipleRegular,
|
||||
AlertRegular,
|
||||
LibraryRegular
|
||||
LibraryRegular,
|
||||
LinkRegular,
|
||||
DismissRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaItem, Source } from '@shared/ipc'
|
||||
import { useSources } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
@@ -70,6 +73,22 @@ const useStyles = makeStyles({
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
suggestion: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
suggestionText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -252,6 +271,9 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Offer a freshly-copied link the way the Downloads tab does, but skip single
|
||||
// videos — a library source is a channel/playlist to sync, not a one-off.
|
||||
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||
@@ -515,6 +537,30 @@ export function LibraryView(): React.JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clip.suggestion && (
|
||||
<div className={styles.suggestion}>
|
||||
<LinkRegular />
|
||||
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
const link = clip.accept()
|
||||
if (link) setUrl(link)
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={clip.dismiss}
|
||||
aria-label="Dismiss"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -236,6 +236,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||||
const updateToken = useSettings((s) => s.updateToken)
|
||||
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||
@@ -561,7 +563,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Keep running in the tray"
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting. Use the tray icon to reopen or quit."
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting — so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
|
||||
>
|
||||
<Switch
|
||||
checked={minimizeToTray}
|
||||
@@ -569,6 +571,17 @@ export function SettingsView(): React.JSX.Element {
|
||||
label={minimizeToTray ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Start with Windows"
|
||||
hint="Launch AeroFetch automatically when you sign in — useful with auto-download so watched channels stay current in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={launchAtStartup}
|
||||
onChange={(_, d) => update({ launchAtStartup: d.checked })}
|
||||
label={launchAtStartup ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
@@ -953,6 +966,19 @@ export function SettingsView(): React.JSX.Element {
|
||||
{appChecking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the release server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only Gitea token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional — Gitea access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{appUpd?.ok && appUpd.available && (
|
||||
<>
|
||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||
|
||||
@@ -39,7 +39,9 @@ if (import.meta.env.DEV && !window.api) {
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: true,
|
||||
minimizeToTray: false
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
updateToken: ''
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -35,7 +35,9 @@ const FALLBACK: Settings = {
|
||||
// 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,
|
||||
minimizeToTray: false
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
updateToken: ''
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSettings } from './store/settings'
|
||||
|
||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||
export function looksLikeUrl(text: string): boolean {
|
||||
const t = text.trim()
|
||||
if (!/^https?:\/\//i.test(t)) return false
|
||||
try {
|
||||
new URL(t)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for URLs that point at a single video rather than a channel/playlist. The
|
||||
* library's add-source field wants collections to sync, not one-off videos, so it
|
||||
* filters these out of its suggestions. Conservative on purpose: it only flags the
|
||||
* common YouTube single-video shapes (a `/watch?v=` or `youtu.be/<id>` with no
|
||||
* `list=` context), so a channel/playlist link — on YouTube or any other site — is
|
||||
* never wrongly rejected. A `list=` param means "add the playlist", so it's kept.
|
||||
*/
|
||||
export function looksLikeSingleVideo(text: string): boolean {
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(text.trim())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (u.searchParams.has('list')) return false
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase()
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') {
|
||||
return u.pathname === '/watch' && u.searchParams.has('v')
|
||||
}
|
||||
if (host === 'youtu.be') {
|
||||
return /^\/[\w-]{6,}$/.test(u.pathname)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Where an offered link came from — drives the banner's wording. */
|
||||
export type SuggestionSource = 'clipboard' | 'external'
|
||||
|
||||
interface ClipboardLink {
|
||||
/** the link currently being offered, or null */
|
||||
suggestion: string | null
|
||||
/** where `suggestion` came from: a clipboard guess vs. a link handed in from outside */
|
||||
source: SuggestionSource
|
||||
/** accept the suggestion: clears it and returns the link (or null if none) */
|
||||
accept: () => string | null
|
||||
/** dismiss the suggestion without using it */
|
||||
dismiss: () => void
|
||||
/**
|
||||
* Offer a link from outside the clipboard — e.g. the aerofetch:// protocol or a
|
||||
* "Send to" .url file. Always takes priority over a clipboard guess and is shown
|
||||
* even when clipboard-watch is off (it's a direct request, not a guess) and even
|
||||
* while the field has text (the banner only fills the field once accepted).
|
||||
* Stable identity, so it's safe to use as an effect dependency.
|
||||
*/
|
||||
offer: (url: string, source?: SuggestionSource) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the clipboard on window focus and offers a freshly-copied http(s) link.
|
||||
* Shared by the download bar and the library's add-source field so both fields
|
||||
* can auto-suggest a copied URL. Respects the `clipboardWatch` setting and never
|
||||
* interrupts text the user is already typing — pass the field's current value as
|
||||
* `currentValue` and the watcher stays quiet while it's non-empty.
|
||||
*
|
||||
* Pass `filter` to narrow what the clipboard offers — e.g. the library skips
|
||||
* single-video links so it only suggests channels/playlists. It gates only the
|
||||
* clipboard guess; links pushed via `offer()` bypass it (and the other guards),
|
||||
* since they're explicit requests rather than guesses.
|
||||
*/
|
||||
export function useClipboardLink(
|
||||
currentValue: string,
|
||||
filter?: (url: string) => boolean
|
||||
): ClipboardLink {
|
||||
const [suggestion, setSuggestion] = useState<string | null>(null)
|
||||
const [source, setSource] = useState<SuggestionSource>('clipboard')
|
||||
const valueRef = useRef('')
|
||||
valueRef.current = currentValue
|
||||
// Held in a ref so an inline `filter` doesn't have to be memoized to keep the
|
||||
// focus listener from re-subscribing every render.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
// The last link we offered or that the user dismissed — so re-focusing doesn't
|
||||
// keep re-offering the same one.
|
||||
const lastSeen = useRef<string | null>(null)
|
||||
// Mirror the live suggestion so accept/dismiss can read it without depending on
|
||||
// it — keeps their identity stable across renders.
|
||||
const suggestionRef = useRef<string | null>(null)
|
||||
suggestionRef.current = suggestion
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
async function check(): Promise<void> {
|
||||
if (!useSettings.getState().clipboardWatch || valueRef.current.trim()) return
|
||||
let text = ''
|
||||
try {
|
||||
text = (await window.api.readClipboard()) ?? ''
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!active) return
|
||||
text = text.trim()
|
||||
const wanted = looksLikeUrl(text) && (filterRef.current?.(text) ?? true)
|
||||
if (wanted && text !== lastSeen.current) {
|
||||
setSource('clipboard')
|
||||
setSuggestion(text)
|
||||
}
|
||||
}
|
||||
check()
|
||||
window.addEventListener('focus', check)
|
||||
return () => {
|
||||
active = false
|
||||
window.removeEventListener('focus', check)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const accept = useCallback((): string | null => {
|
||||
const link = suggestionRef.current
|
||||
if (!link) return null
|
||||
lastSeen.current = link
|
||||
setSuggestion(null)
|
||||
return link
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback((): void => {
|
||||
lastSeen.current = suggestionRef.current
|
||||
setSuggestion(null)
|
||||
}, [])
|
||||
|
||||
const offer = useCallback((url: string, src: SuggestionSource = 'external'): void => {
|
||||
setSource(src)
|
||||
setSuggestion(url)
|
||||
}, [])
|
||||
|
||||
return { suggestion, source, accept, dismiss, offer }
|
||||
}
|
||||
@@ -525,6 +525,15 @@ export interface Settings {
|
||||
hasCompletedOnboarding: boolean
|
||||
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
||||
minimizeToTray: boolean
|
||||
/** launch AeroFetch automatically when the user signs in to Windows */
|
||||
launchAtStartup: boolean
|
||||
/**
|
||||
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
|
||||
* only where the release repo allows anonymous access). When the release repo
|
||||
* is private / the instance requires sign-in, paste a read-only token so the
|
||||
* updater can check + download. Stored locally in settings, never shipped.
|
||||
*/
|
||||
updateToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { looksLikeUrl, looksLikeSingleVideo } from '../src/renderer/src/useClipboardLink'
|
||||
|
||||
describe('looksLikeUrl', () => {
|
||||
it('accepts http(s) URLs', () => {
|
||||
expect(looksLikeUrl('https://youtube.com/watch?v=abc')).toBe(true)
|
||||
expect(looksLikeUrl('http://example.com')).toBe(true)
|
||||
expect(looksLikeUrl('https://example.com/a/b?c=d#frag')).toBe(true)
|
||||
})
|
||||
|
||||
it('is scheme-case-insensitive', () => {
|
||||
expect(looksLikeUrl('HTTPS://example.com')).toBe(true)
|
||||
expect(looksLikeUrl('HtTp://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(looksLikeUrl(' https://example.com\n')).toBe(true)
|
||||
expect(looksLikeUrl('\t http://example.com \t')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-http(s) schemes', () => {
|
||||
expect(looksLikeUrl('ftp://example.com')).toBe(false)
|
||||
expect(looksLikeUrl('file:///c:/x')).toBe(false)
|
||||
expect(looksLikeUrl('magnet:?xt=urn:btih:abc')).toBe(false)
|
||||
expect(looksLikeUrl('javascript:alert(1)')).toBe(false)
|
||||
expect(looksLikeUrl('mailto:a@b.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects bare domains and scheme-less text', () => {
|
||||
expect(looksLikeUrl('www.example.com')).toBe(false)
|
||||
expect(looksLikeUrl('example.com/watch')).toBe(false)
|
||||
expect(looksLikeUrl('see http://example.com')).toBe(false) // must start with the scheme
|
||||
})
|
||||
|
||||
it('rejects empty, whitespace, and malformed input', () => {
|
||||
expect(looksLikeUrl('')).toBe(false)
|
||||
expect(looksLikeUrl(' ')).toBe(false)
|
||||
expect(looksLikeUrl('not a link')).toBe(false)
|
||||
expect(looksLikeUrl('http://')).toBe(false) // passes the prefix test but is not a valid URL
|
||||
})
|
||||
})
|
||||
|
||||
describe('looksLikeSingleVideo', () => {
|
||||
it('flags YouTube single-video URLs', () => {
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe(true)
|
||||
expect(looksLikeSingleVideo('https://youtube.com/watch?v=abc123')).toBe(true)
|
||||
expect(looksLikeSingleVideo('https://m.youtube.com/watch?v=abc123')).toBe(true)
|
||||
expect(looksLikeSingleVideo('https://music.youtube.com/watch?v=abc123')).toBe(true)
|
||||
expect(looksLikeSingleVideo('https://youtu.be/dQw4w9WgXcQ')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps anything with a playlist context (?list=)', () => {
|
||||
// A video opened in a playlist → the user likely wants the whole playlist.
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/watch?v=abc&list=PL123')).toBe(false)
|
||||
expect(looksLikeSingleVideo('https://youtu.be/abc?list=PL123')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps channels and playlists', () => {
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/@SomeChannel')).toBe(false)
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/channel/UC123')).toBe(false)
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/c/SomeChannel')).toBe(false)
|
||||
expect(looksLikeSingleVideo('https://www.youtube.com/playlist?list=PL123')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag single videos on other sites (conservative)', () => {
|
||||
// We can't reliably classify arbitrary hosts, so never reject them.
|
||||
expect(looksLikeSingleVideo('https://vimeo.com/123456789')).toBe(false)
|
||||
expect(looksLikeSingleVideo('https://example.com/watch?v=abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-URLs', () => {
|
||||
expect(looksLikeSingleVideo('not a url')).toBe(false)
|
||||
expect(looksLikeSingleVideo('')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user