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:
@@ -25,11 +25,14 @@ import {
|
||||
AppsListRegular,
|
||||
VideoClipMultipleRegular,
|
||||
AlertRegular,
|
||||
LibraryRegular
|
||||
LibraryRegular,
|
||||
LinkRegular,
|
||||
DismissRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaItem, Source } from '@shared/ipc'
|
||||
import { useSources } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useClipboardLink } from '../useClipboardLink'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
@@ -70,6 +73,22 @@ const useStyles = makeStyles({
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
suggestion: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
suggestionText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -252,6 +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)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||
@@ -515,6 +537,30 @@ export function LibraryView(): React.JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clip.suggestion && (
|
||||
<div className={styles.suggestion}>
|
||||
<LinkRegular />
|
||||
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
const link = clip.accept()
|
||||
if (link) setUrl(link)
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={clip.dismiss}
|
||||
aria-label="Dismiss"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -236,6 +236,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||||
const updateToken = useSettings((s) => s.updateToken)
|
||||
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||
@@ -561,7 +563,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Keep running in the tray"
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting. Use the tray icon to reopen or quit."
|
||||
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting — so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
|
||||
>
|
||||
<Switch
|
||||
checked={minimizeToTray}
|
||||
@@ -569,6 +571,17 @@ export function SettingsView(): React.JSX.Element {
|
||||
label={minimizeToTray ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Start with Windows"
|
||||
hint="Launch AeroFetch automatically when you sign in — useful with auto-download so watched channels stay current in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={launchAtStartup}
|
||||
onChange={(_, d) => update({ launchAtStartup: d.checked })}
|
||||
label={launchAtStartup ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
@@ -953,6 +966,19 @@ export function SettingsView(): React.JSX.Element {
|
||||
{appChecking && <Spinner size="tiny" />}
|
||||
</div>
|
||||
|
||||
<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."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional — Gitea access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{appUpd?.ok && appUpd.available && (
|
||||
<>
|
||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||
|
||||
@@ -39,7 +39,9 @@ if (import.meta.env.DEV && !window.api) {
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: true,
|
||||
minimizeToTray: false
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
updateToken: ''
|
||||
}
|
||||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
||||
// sign-in/clear flow be exercised in this browser-only preview.
|
||||
|
||||
@@ -35,7 +35,9 @@ const FALLBACK: Settings = {
|
||||
// True in preview so design work isn't blocked behind the welcome screen;
|
||||
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||
hasCompletedOnboarding: PREVIEW,
|
||||
minimizeToTray: false
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
updateToken: ''
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user