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