Encrypt credentials at rest; unify clipboard-link suggestions

Encrypt proxy / youtubePoToken / updateToken on disk via safeStorage (DPAPI on Windows), with a one-time launch migration for legacy plaintext. Decrypted before reaching callers/renderer; backup export still writes clear, as documented. Harden updater token handling to refuse cross-origin redirects on the authenticated REST check.

Extract the clipboard-link logic from DownloadBar into the shared useClipboardLink hook: add an optional filter (library skips single-video links), an offer() for external aerofetch:// / .url links, and a source field driving banner wording. Add looksLikeSingleVideo plus its unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 10:15:59 -04:00
parent f167c02946
commit 5a9f2de390
8 changed files with 278 additions and 100 deletions
+15 -66
View File
@@ -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
+4 -4
View File
@@ -32,7 +32,7 @@ import {
import type { MediaItem, Source } from '@shared/ipc'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useClipboardLink } from '../useClipboardLink'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
@@ -271,9 +271,9 @@ export function LibraryView(): React.JSX.Element {
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
// Offer a freshly-copied channel/playlist link, the same way the Downloads tab
// offers a copied video link.
const clip = useClipboardLink(url)
// 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)
+1 -1
View File
@@ -968,7 +968,7 @@ export function SettingsView(): React.JSX.Element {
<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 locally; leave blank for anonymous access."
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"
+83 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSettings } from './store/settings'
/** A quick heuristic for "this clipboard text is a link worth offering". */
@@ -13,13 +13,52 @@ export function looksLikeUrl(text: string): boolean {
}
}
/**
* 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 freshly-copied link being offered, or null */
/** 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
}
/**
@@ -28,14 +67,31 @@ interface ClipboardLink {
* 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): ClipboardLink {
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
@@ -49,7 +105,11 @@ export function useClipboardLink(currentValue: string): ClipboardLink {
}
if (!active) return
text = text.trim()
if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text)
const wanted = looksLikeUrl(text) && (filterRef.current?.(text) ?? true)
if (wanted && text !== lastSeen.current) {
setSource('clipboard')
setSuggestion(text)
}
}
check()
window.addEventListener('focus', check)
@@ -59,18 +119,23 @@ export function useClipboardLink(currentValue: string): ClipboardLink {
}
}, [])
return {
suggestion,
accept: () => {
if (!suggestion) return null
lastSeen.current = suggestion
const accepted = suggestion
setSuggestion(null)
return accepted
},
dismiss: () => {
lastSeen.current = suggestion
setSuggestion(null)
}
}
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 }
}