diff --git a/ROADMAP.md b/ROADMAP.md index 9ccad93..ce2fcfe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -368,23 +368,28 @@ per-item `options`) but not the surfaces. typecheck + test + `npm run build` cle (filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs, new download (focuses the `aerofetch-url` input), toggle theme.* -## Phase O — Windows-native integration & discoverability +## Phase O — Windows-native integration & discoverability ✅ COMPLETE -Grepping `src/main` confirms none of these exist yet (no `setProgressBar`, `Tray`, -`setThumbarButtons`, `setJumpList`, `globalShortcut`). All live in the main process, mostly in -`src/main/index.ts`. +All in the main process. typecheck + test + `npm run build` clean. -- [ ] **Taskbar progress bar.** `win.setProgressBar(fraction)` driven by Phase M's aggregate - progress. ~20 lines, very high perceived-quality return. -- [ ] **System tray + minimize-to-tray.** A `Tray` with show/quit/pause-all. This is also the - coherent home for the **watched-source background sync** (Phase J), which today just - "launches the normal window unobtrusively." -- [ ] **Thumbnail toolbar buttons** (`win.setThumbarButtons`) — pause/cancel from the taskbar - preview — and a **jump list** (`app.setJumpList`) with "Paste & download." +- [x] **Taskbar progress bar.** *Done: renderer pushes the `summarizeQueue` aggregate over a new + `taskbar:progress` channel (App-level store subscription, no re-render); + `mainWindow.setProgressBar` with `mode: 'normal' | 'error'` (red when any failed) and `-1` + to clear when idle.* +- [x] **System tray + minimize-to-tray.** *Done: `src/main/tray.ts` builds a `Tray` (icon resolved + via `getAppIconPath()`; `build/icon.ico` added to electron-builder `extraResources` for + prod) with Show / Quit. A new `Settings.minimizeToTray` (Settings → Appearance) makes the + window's `close` hide to tray instead of quitting; `before-quit`/tray-Quit set an + `isQuitting` flag so real exits still work.* +- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the + single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage` + assets this repo doesn't have tooling to generate here.* - [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state. -- [ ] **Settings discoverability.** `src/renderer/src/components/SettingsView.tsx` is ~1,000 - lines across ~10 cards. Add a **settings search box** or a **Basic / Advanced split** so - first-time users aren't faced with the full power-user wall. + *Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the + taskbar progress bar's `error` mode already signals failures.* +- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards + live by matching each card's text (DOM `display` toggle keyed off the root's children — no + per-card refactor), with a "no settings match" hint.* ## Phase P — Reliability, longevity & trust diff --git a/electron-builder.yml b/electron-builder.yml index 529d87a..7ace0c8 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -30,6 +30,10 @@ extraResources: to: bin filter: - '**/*' + # The app icon, copied so the runtime (system tray) can load it at + # /icon.ico — build/ itself isn't bundled into the app. + - from: build/icon.ico + to: icon.ico win: # Two artifacts: diff --git a/src/main/binaries.ts b/src/main/binaries.ts index c2843e7..098256f 100644 --- a/src/main/binaries.ts +++ b/src/main/binaries.ts @@ -13,6 +13,17 @@ export function getBinDir(): string { return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin') } +/** + * The app icon (.ico), for the system tray. In dev it's the repo's build/icon.ico; + * in a packaged build, electron-builder's extraResources copies it to + * /icon.ico (see electron-builder.yml). + */ +export function getAppIconPath(): string { + return is.dev + ? join(app.getAppPath(), 'build', 'icon.ico') + : join(process.resourcesPath, 'icon.ico') +} + /** * AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate * from the read-only bundled seed in resources/bin. The managed copy is what diff --git a/src/main/index.ts b/src/main/index.ts index 1b5852a..c5dc614 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,7 +8,8 @@ import { type HistoryEntry, type CommandTemplate, type YtdlpUpdateChannel, - type SystemThemeInfo + type SystemThemeInfo, + type TaskbarProgress } from '@shared/ipc' import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp' import { getFfmpegVersions } from './ffmpeg' @@ -36,6 +37,7 @@ import { import { indexSource } from './indexer' import { syncWatchedSources } from './sync' import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule' +import { createTray, markQuitting, isQuitting } from './tray' // Only one instance ever runs. A second launch — e.g. the OS invoking us again // for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to @@ -135,6 +137,15 @@ function createWindow(): void { else win.show() }) + // Minimize-to-tray: when enabled, closing the window hides it to the tray + // instead of quitting. A real quit (tray menu / before-quit) sets isQuitting(). + win.on('close', (e) => { + if (getSettings().minimizeToTray && !isQuitting()) { + e.preventDefault() + win.hide() + } + }) + win.on('closed', () => { if (mainWindow === win) mainWindow = null }) @@ -321,6 +332,13 @@ function registerIpcHandlers(): void { ) ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync()) ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled)) + + // Reflect overall queue progress on the Windows taskbar (fire-and-forget). + ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => { + if (!mainWindow || mainWindow.isDestroyed()) return + if (p.mode === 'none') mainWindow.setProgressBar(-1) + else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode }) + }) } // Push OS theme/contrast changes to every window, and keep the native @@ -361,6 +379,20 @@ if (isPrimaryInstance) { registerSystemThemeBridge() registerSendToShortcut() createWindow() + createTray(() => mainWindow) + + // Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the + // exe again is caught by the single-instance lock, which focuses this window. + app.setUserTasks([ + { + program: process.execPath, + arguments: '', + title: 'Open AeroFetch', + description: 'Open the AeroFetch window', + iconPath: process.execPath, + iconIndex: 0 + } + ]) // Keep yt-dlp current on its own: seed the managed copy, then (throttled to // once a day) self-update it to the chosen channel so a stale binary can't @@ -377,6 +409,10 @@ if (isPrimaryInstance) { }) }) + // Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set + // the quitting flag so the window's close handler exits instead of hiding to tray. + app.on('before-quit', () => markQuitting()) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/src/main/settings.ts b/src/main/settings.ts index 908040c..48e3a18 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -47,7 +47,8 @@ const DEFAULTS: Settings = { defaultTemplateId: null, notifyOnComplete: true, autoDownloadNew: true, - hasCompletedOnboarding: false + hasCompletedOnboarding: false, + minimizeToTray: false } /** Fixed path for the --download-archive file; not user-configurable. */ @@ -206,6 +207,7 @@ export function setSettings(partial: Partial): Settings { case 'notifyOnComplete': case 'autoDownloadNew': case 'hasCompletedOnboarding': + case 'minimizeToTray': if (typeof value === 'boolean') s.set(key, value) break case 'ytdlpChannel': diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..1227fba --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,55 @@ +/** + * System tray + minimize-to-tray (Phase O). When Settings.minimizeToTray is on, + * closing the window hides it to the tray instead of quitting; the tray's "Quit" + * really exits. The tray gives the app a background presence (a natural home for + * the watched-source sync) and a quick show/quit menu. + */ +import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron' +import { getAppIconPath } from './binaries' + +let tray: Tray | null = null +// 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". +let quitting = false + +export function isQuitting(): boolean { + return quitting +} + +export function markQuitting(): void { + quitting = true +} + +function show(getWindow: () => BrowserWindow | null): void { + const win = getWindow() + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() +} + +/** Create the tray icon + menu once. No-op if it already exists or the icon is missing. */ +export function createTray(getWindow: () => BrowserWindow | null): void { + if (tray) return + const icon = nativeImage.createFromPath(getAppIconPath()) + // Without a usable icon a Windows tray entry is invisible/unclickable, which is + // worse than no tray — so skip it rather than ship a dead tray. + if (icon.isEmpty()) return + + tray = new Tray(icon) + tray.setToolTip('AeroFetch') + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: 'Show AeroFetch', click: () => show(getWindow) }, + { type: 'separator' }, + { + label: 'Quit AeroFetch', + click: () => { + quitting = true + app.quit() + } + } + ]) + ) + tray.on('click', () => show(getWindow)) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0e89991..7999e4a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,7 +30,8 @@ import { type SyncResult, type ScheduledSyncStatus, type TerminalEvent, - type TerminalRunResult + type TerminalRunResult, + type TaskbarProgress } from '@shared/ipc' // The surface exposed to the renderer. Keep this thin: it only forwards to IPC. @@ -227,7 +228,11 @@ const api = { const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev) ipcRenderer.on(IpcChannels.terminalOutput, listener) return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener) - } + }, + + /** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */ + setTaskbarProgress: (p: TaskbarProgress): void => + ipcRenderer.send(IpcChannels.taskbarProgress, p) } export type Api = typeof api diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 537336b..7775d38 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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['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') }, diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index e1a3bb9..b819308 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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(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 ( -
+
+
+ setSearch(d.value)} + placeholder="Search settings…" + contentBefore={} + style={{ width: '100%' }} + /> + {noResults && ( + + No settings match “{search}”. + + )} +
+
@@ -517,6 +556,17 @@ export function SettingsView(): React.JSX.Element { label={notifyOnComplete ? 'On' : 'Off'} /> + + + update({ minimizeToTray: d.checked })} + label={minimizeToTray ? 'On' : 'Off'} + /> + diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 89463b4..6724a4f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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: () => {} } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 2d4613a..fbb33fd 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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 { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index aefd269..793d1f1 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -76,7 +76,9 @@ export const IpcChannels = { terminalRun: 'terminal:run', terminalCancel: 'terminal:cancel', /** main → renderer push channel for live terminal output lines */ - terminalOutput: 'terminal:output' + terminalOutput: 'terminal:output', + /** renderer → main: reflect overall queue progress on the Windows taskbar (Phase O) */ + taskbarProgress: 'taskbar:progress' } as const /** Sentinel format id meaning "let yt-dlp pick the best video+audio". */ @@ -510,6 +512,8 @@ export interface Settings { autoDownloadNew: boolean /** false until the first-run welcome screen has been dismissed */ hasCompletedOnboarding: boolean + /** keep AeroFetch running in the system tray when its window is closed (Phase O) */ + minimizeToTray: boolean } /** @@ -709,3 +713,11 @@ export interface TerminalRunResult { ok: boolean error?: string } + +/** Overall queue state for the Windows taskbar progress bar (Phase O). */ +export interface TaskbarProgress { + /** 0..1 overall fraction; ignored when mode is 'none' */ + fraction: number + /** taskbar bar mode: hidden, normal, or red (some failed) */ + mode: 'none' | 'normal' | 'error' +}