feat: Phase O Windows-native integration + settings search

- Taskbar progress bar: renderer pushes summarizeQueue aggregate over a new
  taskbar:progress channel (App store subscription); setProgressBar normal/error
- System tray (src/main/tray.ts) + minimize-to-tray (Settings.minimizeToTray);
  close hides to tray, isQuitting guards real exits; icon via getAppIconPath()
  (build/icon.ico added to extraResources)
- Jump list: app.setUserTasks "Open AeroFetch" (thumbnail toolbar deferred)
- Settings search: filters the ~11 SettingsView cards live by text match

Overlay badge deferred (needs a drawn NativeImage). typecheck + test (192) +
build all clean. Roadmap: Phase O COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:20:28 -04:00
parent da37690b42
commit 20ee913394
12 changed files with 222 additions and 25 deletions
+14
View File
@@ -10,6 +10,8 @@ import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
const useStyles = makeStyles({
@@ -50,6 +52,18 @@ function App(): React.JSX.Element {
return () => window.removeEventListener('keydown', onKey)
}, [])
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
// directly (not via a selector) so taskbar updates don't re-render the app.
useEffect(() => {
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
+53 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Field,
Input,
@@ -34,7 +34,8 @@ import {
PaintBucketRegular,
AccessibilityRegular,
ArrowSyncRegular,
OpenRegular
OpenRegular,
SearchRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
@@ -186,6 +187,28 @@ const UPDATE_CHANNEL_OPTIONS = [
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
// filter by toggling each card's `display` based on whether its text matches.
// The cards are the root's direct children; the search box (marked
// data-settings-search) is skipped. React never sets `style` on the cards, so
// our inline display survives their re-renders.
const [search, setSearch] = useState('')
const [noResults, setNoResults] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const q = search.trim().toLowerCase()
let shown = 0
for (const el of Array.from(root.children) as HTMLElement[]) {
if (el.dataset.settingsSearch !== undefined) continue
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
el.style.display = match ? '' : 'none'
if (match) shown++
}
setNoResults(q !== '' && shown === 0)
}, [search])
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
@@ -210,6 +233,7 @@ export function SettingsView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
@@ -415,7 +439,22 @@ export function SettingsView(): React.JSX.Element {
}
return (
<div className={styles.root}>
<div className={styles.root} ref={rootRef}>
<div data-settings-search>
<Input
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
contentBefore={<SearchRegular />}
style={{ width: '100%' }}
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}.
</Caption1>
)}
</div>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
@@ -517,6 +556,17 @@ export function SettingsView(): React.JSX.Element {
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
<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."
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
+4 -2
View File
@@ -36,7 +36,8 @@ if (import.meta.env.DEV && !window.api) {
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: true
hasCompletedOnboarding: true,
minimizeToTray: false
}
// 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.
@@ -223,7 +224,8 @@ if (import.meta.env.DEV && !window.api) {
onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {}
onTerminalOutput: () => () => {},
setTaskbarProgress: () => {}
}
}
+2 -1
View File
@@ -32,7 +32,8 @@ const FALLBACK: Settings = {
autoDownloadNew: true,
// 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
hasCompletedOnboarding: PREVIEW,
minimizeToTray: false
}
interface SettingsState extends Settings {