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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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