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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fluentui/react-components": "^9.74.1",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ interface ActiveDownload {
|
|||||||
|
|
||||||
const active = new Map<string, ActiveDownload>()
|
const active = new Map<string, ActiveDownload>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||||
|
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||||
|
* spawned processes) when the user closes the window mid-download.
|
||||||
|
*/
|
||||||
|
export function hasActiveDownloads(): boolean {
|
||||||
|
return active.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||||
|
|
||||||
function num(s?: string): number | undefined {
|
function num(s?: string): number | undefined {
|
||||||
|
|||||||
+37
-6
@@ -1,4 +1,4 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import {
|
import {
|
||||||
@@ -15,9 +15,15 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
|||||||
import { getFfmpegVersions } from './ffmpeg'
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
import {
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
pauseDownload,
|
||||||
|
previewCommand,
|
||||||
|
hasActiveDownloads
|
||||||
|
} from './download'
|
||||||
import { runTerminal, cancelTerminal } from './terminal'
|
import { runTerminal, cancelTerminal } from './terminal'
|
||||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
import { getSettings, setSettings, ensureMediaDirs, applyLaunchAtStartup } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
@@ -95,6 +101,19 @@ function getSystemThemeInfo(): SystemThemeInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tell the user (once per run) that closing the window left AeroFetch running so
|
||||||
|
// an in-progress download could finish — shown only when they haven't already
|
||||||
|
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
||||||
|
let notifiedBackground = false
|
||||||
|
function notifyBackgroundOnce(): void {
|
||||||
|
if (notifiedBackground || !Notification.isSupported()) return
|
||||||
|
notifiedBackground = true
|
||||||
|
new Notification({
|
||||||
|
title: 'AeroFetch is still running',
|
||||||
|
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
|
||||||
|
}).show()
|
||||||
|
}
|
||||||
|
|
||||||
// Web permissions a download manager never needs. They're denied for the app
|
// Web permissions a download manager never needs. They're denied for the app
|
||||||
// window as defence-in-depth (audit T6): even if the renderer were compromised
|
// window as defence-in-depth (audit T6): even if the renderer were compromised
|
||||||
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
|
// (e.g. XSS via remote video metadata) it can't open the camera/mic, read
|
||||||
@@ -137,12 +156,20 @@ function createWindow(): void {
|
|||||||
else win.show()
|
else win.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Minimize-to-tray: when enabled, closing the window hides it to the tray
|
// Closing the window hides to the tray (instead of quitting) when either the
|
||||||
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
|
// user opted into background mode, OR a download is in flight — quitting would
|
||||||
|
// kill the spawned yt-dlp processes and lose the download. A real quit (tray
|
||||||
|
// menu / before-quit) sets isQuitting() so this lets the close through.
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
if (getSettings().minimizeToTray && !isQuitting()) {
|
if (isQuitting()) return
|
||||||
|
const downloadsRunning = hasActiveDownloads()
|
||||||
|
if (getSettings().minimizeToTray || downloadsRunning) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
win.hide()
|
win.hide()
|
||||||
|
// If we're only staying alive because a download is running (the user
|
||||||
|
// didn't opt into tray mode), tell them once — otherwise a window that
|
||||||
|
// won't close looks like a bug.
|
||||||
|
if (downloadsRunning && !getSettings().minimizeToTray) notifyBackgroundOnce()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,6 +398,10 @@ if (isPrimaryInstance) {
|
|||||||
// exist from first launch (downloads are routed into them by kind).
|
// exist from first launch (downloads are routed into them by kind).
|
||||||
ensureMediaDirs()
|
ensureMediaDirs()
|
||||||
|
|
||||||
|
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
||||||
|
// reflects the user's choice even if they changed it on another install.
|
||||||
|
applyLaunchAtStartup(getSettings().launchAtStartup)
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|||||||
+27
-1
@@ -50,7 +50,23 @@ const DEFAULTS: Settings = {
|
|||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
autoDownloadNew: true,
|
autoDownloadNew: true,
|
||||||
hasCompletedOnboarding: false,
|
hasCompletedOnboarding: false,
|
||||||
minimizeToTray: false
|
minimizeToTray: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
updateToken: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
|
||||||
|
* startup and whenever the toggle changes. On Windows this writes/removes a
|
||||||
|
* per-user registry Run entry — no admin needed, matching the app's no-elevation
|
||||||
|
* stance. Best-effort: a failure here just means the toggle didn't take effect.
|
||||||
|
*/
|
||||||
|
export function applyLaunchAtStartup(enabled: boolean): void {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: enabled })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — e.g. unsupported platform */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||||
@@ -212,6 +228,12 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'minimizeToTray':
|
case 'minimizeToTray':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
|
case 'launchAtStartup':
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
s.set('launchAtStartup', value)
|
||||||
|
applyLaunchAtStartup(value)
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'ytdlpChannel':
|
case 'ytdlpChannel':
|
||||||
// Same allowlist that guards the `--update-to` flag (audit F1).
|
// Same allowlist that guards the `--update-to` flag (audit F1).
|
||||||
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
|
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
|
||||||
@@ -262,6 +284,10 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'youtubePoToken':
|
case 'youtubePoToken':
|
||||||
if (typeof value === 'string') s.set(key, value)
|
if (typeof value === 'string') s.set(key, value)
|
||||||
break
|
break
|
||||||
|
case 'updateToken':
|
||||||
|
// A Gitea token has no spaces; trim and store as-is (like proxy creds).
|
||||||
|
if (typeof value === 'string') s.set('updateToken', value.trim())
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getSettings()
|
return getSettings()
|
||||||
|
|||||||
+14
-3
@@ -7,6 +7,16 @@
|
|||||||
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
|
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
|
||||||
import { getAppIconPath } from './binaries'
|
import { getAppIconPath } from './binaries'
|
||||||
|
|
||||||
|
// Fallback tray glyph (32×32 teal disc + white download arrow) used when no
|
||||||
|
// build/icon.ico is present. Without this the tray is skipped (an empty image
|
||||||
|
// makes an invisible/unclickable Windows tray entry), which would strand a
|
||||||
|
// minimized-to-tray window with no way back. Embedded as base64 so it needs no
|
||||||
|
// asset-bundling step.
|
||||||
|
const FALLBACK_TRAY_PNG =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAl0lEQVR4nO3TQQ6AIAxEUU7giV17bd0i' +
|
||||||
|
'aaDTmdqY0ISl/peirf11jvO6+/N5cHXKwlIIG6cQqngIoY5DiKy4C4G8aBwJohSArpIBmIgNKAWgDys' +
|
||||||
|
'AL8QGlANmCHZc8dUW1HEYEEFA6/d+B6q4CVAhwnHkb2DiUwCDkMRRBHpccQsRnXB8RLCAULxHMAAqbm0j5b4VoPRg1jzWxpzrS/JA7QAAAABJRU5ErkJggg=='
|
||||||
|
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
// True once the user has chosen to really quit (tray menu, or app.quit from the
|
// True once the user has chosen to really quit (tray menu, or app.quit from the
|
||||||
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
|
// updater) — lets the window 'close' handler tell "hide to tray" from "exit".
|
||||||
@@ -31,9 +41,10 @@ function show(getWindow: () => BrowserWindow | null): void {
|
|||||||
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
|
/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */
|
||||||
export function createTray(getWindow: () => BrowserWindow | null): void {
|
export function createTray(getWindow: () => BrowserWindow | null): void {
|
||||||
if (tray) return
|
if (tray) return
|
||||||
const icon = nativeImage.createFromPath(getAppIconPath())
|
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
|
||||||
// Without a usable icon a Windows tray entry is invisible/unclickable, which is
|
// ships, so minimize-to-tray always has a tray to restore from.
|
||||||
// worse than no tray — so skip it rather than ship a dead tray.
|
let icon = nativeImage.createFromPath(getAppIconPath())
|
||||||
|
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
||||||
if (icon.isEmpty()) return
|
if (icon.isEmpty()) return
|
||||||
|
|
||||||
tray = new Tray(icon)
|
tray = new Tray(icon)
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,7 @@ import { createWriteStream, type WriteStream } from 'fs'
|
|||||||
import { stat, unlink } from 'fs/promises'
|
import { stat, unlink } from 'fs/promises'
|
||||||
import { join, normalize, dirname } from 'path'
|
import { join, normalize, dirname } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
|
import { getSettings } from './settings'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type AppUpdateInfo,
|
type AppUpdateInfo,
|
||||||
@@ -10,6 +11,18 @@ import {
|
|||||||
type AppUpdateProgress
|
type AppUpdateProgress
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization header for the update host. Empty unless the user has set an
|
||||||
|
* updateToken in Settings — needed when the release repo is private or the Gitea
|
||||||
|
* instance requires sign-in for anonymous access (the default on this instance).
|
||||||
|
* Only ever sent to the host-pinned UPDATE_HOST (see isTrustedDownloadUrl), so a
|
||||||
|
* redirect can never leak the token to another origin.
|
||||||
|
*/
|
||||||
|
function authHeader(): Record<string, string> {
|
||||||
|
const tok = getSettings().updateToken?.trim()
|
||||||
|
return tok ? { Authorization: `token ${tok}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Update source -----------------------------------------------------------
|
// --- Update source -----------------------------------------------------------
|
||||||
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
||||||
// the repo's latest release over the public REST API and downloads the installer
|
// the repo's latest release over the public REST API and downloads the installer
|
||||||
@@ -109,7 +122,7 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
|
|||||||
const timer = setTimeout(() => controller.abort(), 15_000)
|
const timer = setTimeout(() => controller.abort(), 15_000)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(RELEASE_API, {
|
const res = await fetch(RELEASE_API, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json', ...authHeader() },
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -187,6 +200,7 @@ function fetchTrustedText(
|
|||||||
resolve(r)
|
resolve(r)
|
||||||
}
|
}
|
||||||
const request = net.request({ url, redirect: 'manual' })
|
const request = net.request({ url, redirect: 'manual' })
|
||||||
|
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
|
||||||
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
const timer = setTimeout(() => done({ ok: false, error: 'timed out' }), timeoutMs)
|
||||||
request.on('redirect', (_s, _m, redirectUrl) => {
|
request.on('redirect', (_s, _m, redirectUrl) => {
|
||||||
if (!isTrustedDownloadUrl(redirectUrl)) {
|
if (!isTrustedDownloadUrl(redirectUrl)) {
|
||||||
@@ -279,6 +293,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
|||||||
// fetch hides the Location header under redirect:'manual', so it can't gate
|
// fetch hides the Location header under redirect:'manual', so it can't gate
|
||||||
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
// hops. 'manual' here means a hop only proceeds if we call followRedirect().
|
||||||
const request = net.request({ url, redirect: 'manual' })
|
const request = net.request({ url, redirect: 'manual' })
|
||||||
|
for (const [k, v] of Object.entries(authHeader())) request.setHeader(k, v)
|
||||||
|
|
||||||
const armIdle = (): void => {
|
const armIdle = (): void => {
|
||||||
if (idle) clearTimeout(idle)
|
if (idle) clearTimeout(idle)
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ import {
|
|||||||
AppsListRegular,
|
AppsListRegular,
|
||||||
VideoClipMultipleRegular,
|
VideoClipMultipleRegular,
|
||||||
AlertRegular,
|
AlertRegular,
|
||||||
LibraryRegular
|
LibraryRegular,
|
||||||
|
LinkRegular,
|
||||||
|
DismissRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { MediaItem, Source } from '@shared/ipc'
|
import type { MediaItem, Source } from '@shared/ipc'
|
||||||
import { useSources } from '../store/sources'
|
import { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useClipboardLink } from '../useClipboardLink'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
import { thumbUrl } from '../thumb'
|
import { thumbUrl } from '../thumb'
|
||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
@@ -70,6 +73,22 @@ const useStyles = makeStyles({
|
|||||||
sub: { color: tokens.colorNeutralForeground3 },
|
sub: { color: tokens.colorNeutralForeground3 },
|
||||||
addRow: { display: 'flex', gap: '8px' },
|
addRow: { display: 'flex', gap: '8px' },
|
||||||
addInput: { flexGrow: 1 },
|
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: {
|
toolbar: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -252,6 +271,9 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||||
@@ -515,6 +537,30 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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}>
|
<div className={styles.toolbar}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
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 autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||||
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||||
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||||
@@ -561,7 +563,7 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Keep running in the tray"
|
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
|
<Switch
|
||||||
checked={minimizeToTray}
|
checked={minimizeToTray}
|
||||||
@@ -569,6 +571,17 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
label={minimizeToTray ? 'On' : 'Off'}
|
label={minimizeToTray ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -953,6 +966,19 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
{appChecking && <Spinner size="tiny" />}
|
{appChecking && <Spinner size="tiny" />}
|
||||||
</div>
|
</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 && (
|
{appUpd?.ok && appUpd.available && (
|
||||||
<>
|
<>
|
||||||
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
notifyOnComplete: true,
|
notifyOnComplete: true,
|
||||||
autoDownloadNew: true,
|
autoDownloadNew: true,
|
||||||
hasCompletedOnboarding: true,
|
hasCompletedOnboarding: true,
|
||||||
minimizeToTray: false
|
minimizeToTray: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
updateToken: ''
|
||||||
}
|
}
|
||||||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
// 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.
|
// 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;
|
// 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.
|
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||||
hasCompletedOnboarding: PREVIEW,
|
hasCompletedOnboarding: PREVIEW,
|
||||||
minimizeToTray: false
|
minimizeToTray: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
updateToken: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsState extends Settings {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -525,6 +525,15 @@ export interface Settings {
|
|||||||
hasCompletedOnboarding: boolean
|
hasCompletedOnboarding: boolean
|
||||||
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
|
||||||
minimizeToTray: boolean
|
minimizeToTray: boolean
|
||||||
|
/** launch AeroFetch automatically when the user signs in to Windows */
|
||||||
|
launchAtStartup: boolean
|
||||||
|
/**
|
||||||
|
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
|
||||||
|
* only where the release repo allows anonymous access). When the release repo
|
||||||
|
* is private / the instance requires sign-in, paste a read-only token so the
|
||||||
|
* updater can check + download. Stored locally in settings, never shipped.
|
||||||
|
*/
|
||||||
|
updateToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user