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
+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 }
}