Background running for downloads/auto-download, library clipboard detect, updater auth + token

Closing the window no longer kills in-progress downloads, the library gains the
same copied-link suggestion the downloads tab has, and the in-app updater can now
authenticate to a sign-in-required Gitea.

Background / tray:
- The window's close handler now hides to the tray (instead of quitting) whenever
  a download is in flight, even if "Keep running in the tray" is off — quitting
  was killing the spawned yt-dlp processes. A one-time notification explains the
  app is still running. (download.ts exposes hasActiveDownloads().)
- tray.ts now falls back to an embedded icon when no build/icon.ico ships, so the
  tray actually appears — previously an empty icon meant the tray was skipped and
  a minimized window could be stranded with no way back.
- New "Start with Windows" setting (launchAtStartup) wired to
  app.setLoginItemSettings, synced at startup and on toggle. Useful with
  auto-download so watched channels stay current in the background.
- The "Keep running in the tray" hint now explains it also enables background
  auto-download of new uploads.

Library clipboard detection:
- Extracted the downloads tab's clipboard watcher into a shared useClipboardLink
  hook and used it in the library's add-source field, so a copied channel/playlist
  link is offered there too.

Updater fix (works on a private / sign-in-required instance):
- Added an optional updateToken setting. When set, the updater sends it as a Gitea
  Authorization header on the release check, the checksum fetch, and the installer
  download — so "Check for updates" works where anonymous access is blocked (the
  previous "could not reach the update server" case). Blank = anonymous, unchanged.
  No token is ever shipped; it's only ever sent to the host-pinned update host.
  Settings gains a masked "Update access token" field.

Typecheck clean; 187 tests pass; electron-vite build clean. New UI verified in the
browser preview (tray/startup toggles, token field, library copied-link banner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 12:22:19 -04:00
parent e2b33603c6
commit f167c02946
12 changed files with 270 additions and 17 deletions
+76
View File
@@ -0,0 +1,76 @@
import { 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
}
}
interface ClipboardLink {
/** the freshly-copied link being offered, or null */
suggestion: string | null
/** accept the suggestion: clears it and returns the link (or null if none) */
accept: () => string | null
/** dismiss the suggestion without using it */
dismiss: () => 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.
*/
export function useClipboardLink(currentValue: string): ClipboardLink {
const [suggestion, setSuggestion] = useState<string | null>(null)
const valueRef = useRef('')
valueRef.current = currentValue
// 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)
useEffect(() => {
let active = true
async function check(): Promise<void> {
if (!useSettings.getState().clipboardWatch || valueRef.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) setSuggestion(text)
}
check()
window.addEventListener('focus', check)
return () => {
active = false
window.removeEventListener('focus', check)
}
}, [])
return {
suggestion,
accept: () => {
if (!suggestion) return null
lastSeen.current = suggestion
const accepted = suggestion
setSuggestion(null)
return accepted
},
dismiss: () => {
lastSeen.current = suggestion
setSuggestion(null)
}
}
}