diff --git a/package-lock.json b/package-lock.json index 904574b..282ce1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/main/download.ts b/src/main/download.ts index 48f424b..a61b834 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -43,6 +43,15 @@ interface ActiveDownload { const active = new Map() +/** + * 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 { diff --git a/src/main/index.ts b/src/main/index.ts index c5dc614..02b4dfe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) }) diff --git a/src/main/settings.ts b/src/main/settings.ts index 96c2558..fa67807 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 { 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 { 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 { 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() diff --git a/src/main/tray.ts b/src/main/tray.ts index 1227fba..4545cfd 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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) diff --git a/src/main/updater.ts b/src/main/updater.ts index 1b4e940..c744d0a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 { + 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 { 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 { if (idle) clearTimeout(idle) diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 9fea3e1..5932909 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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>({}) - // 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(null) - const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard') - const urlRef = useRef('') - urlRef.current = url - const lastSeen = useRef(null) - - useEffect(() => { - let active = true - async function check(): Promise { - 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 diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 42ab3ea..929b9ee 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -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(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>(new Set()) const [batchNote, setBatchNote] = useState(null) const [syncNote, setSyncNote] = useState(null) @@ -515,6 +537,30 @@ export function LibraryView(): React.JSX.Element { + {clip.suggestion && ( +
+ + Use copied link? {clip.suggestion} + +
+ )} +
+ + update({ updateToken: d.value })} + contentBefore={} + /> + + {appUpd?.ok && appUpd.available && ( <> diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 81c4d4e..f793de3 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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. diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index d147810..0fec846 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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 { diff --git a/src/renderer/src/useClipboardLink.ts b/src/renderer/src/useClipboardLink.ts new file mode 100644 index 0000000..a8f7769 --- /dev/null +++ b/src/renderer/src/useClipboardLink.ts @@ -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/` 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(null) + const [source, setSource] = useState('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(null) + // Mirror the live suggestion so accept/dismiss can read it without depending on + // it — keeps their identity stable across renders. + const suggestionRef = useRef(null) + suggestionRef.current = suggestion + + useEffect(() => { + let active = true + async function check(): Promise { + 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 } +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a6e6689..9f5e90f 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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 } /** diff --git a/test/clipboardLink.test.ts b/test/clipboardLink.test.ts new file mode 100644 index 0000000..047e014 --- /dev/null +++ b/test/clipboardLink.test.ts @@ -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) + }) +})