diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index e9cd382..6a1282e 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -1175,8 +1175,9 @@ root cause is already filed. - [ ] **UX17 — Icon-only Fetch/Paste buttons** rely on hover tooltips for meaning (UI a11y; new users on touch/keyboard miss them). **Fix:** labels or `aria`-described affordances. - [ ] **UX18 — The quality control morphs after Fetch** (preset list → real formats; label "Quality" → "Quality / format") in place — a surprising transform. **Fix:** keep a stable control with a clear "formats loaded" state. -- [ ] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed - 920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. +- [x] **UX19 — Window size/position isn't remembered.** [index.ts](src/main/index.ts) opens a fixed + 920×700 every launch (no bounds persistence). **Fix:** persist + restore window bounds. *Fixed with W2 — + [windowState.ts](src/main/windowState.ts) persists + restores bounds/maximized with an off-screen guard.* - [ ] **UX20 — "App is still running" surprise.** Closing with a download active hides to tray and notifies once per process (L68); later closes give no hint, so the window "won't close" reads as a bug. **Fix:** a persistent tray hint / first-close explainer. @@ -1207,9 +1208,14 @@ DPI handled by Chromium). The deviations: - [x] **W1 — No minimum window size.** `createWindow` sets `width/height` but no `minWidth`/`minHeight` ([index.ts](src/main/index.ts)), so the window can be dragged down to a few pixels and the layout (212px sidebar + content) breaks. **Standard:** set a sensible min (e.g. 640×480). -- [ ] **W2 — Window placement isn't persisted** (extends UX19): size, position, **maximized state**, and +- [x] **W2 — Window placement isn't persisted** (extends UX19): size, position, **maximized state**, and **which monitor** are all forgotten — it reopens 920×700 on the primary display every launch. **Standard:** persist & restore window placement per Windows app convention (e.g. `electron-window-state`). + *Fixed (no new dep): [windowState.ts](src/main/windowState.ts) persists the window's normal bounds + + maximized flag to `/window-state.json`; `createWindow` opens from the saved state (debounced + save on resize/move + a save on close, which may only hide to tray). A `screen.getAllDisplays()` + visibility check falls back to the default centered size when the saved monitor is gone, so the window + can never restore off-screen. (The restore cycle itself wants a live quit→relaunch confirm.)* - [x] **W3 — Title bar doesn't follow the in-app theme.** `nativeTheme.themeSource` is deliberately never set (index.ts:97), so the OS-drawn caption follows the *OS* theme. Choosing an explicit in-app **dark** theme on a **light** OS leaves a light title bar on a dark app (and vice-versa). **Standard:** set @@ -1244,12 +1250,19 @@ DPI handled by Chromium). The deviations: ### System tray, taskbar & icons -- [ ] **W10 — No taskbar attention flash.** When a background download completes while the window is +- [x] **W10 — No taskbar attention flash.** When a background download completes while the window is minimized/hidden, the app doesn't `flashFrame` the taskbar button. **Standard:** flash for attention on - background completion (pairs with the existing notification). -- [ ] **W11 — No taskbar overlay badge** (deferred in roadmap): a download manager conventionally shows an + background completion (pairs with the existing notification). *Fixed: `notify()` in + [download.ts](src/main/download.ts) calls `win.flashFrame(true)` when the completion/failure event lands + while the window is unfocused/minimized/hidden (Windows stops the flash on focus). Gated on the same + `notifyOnComplete` as the toast, so it pairs with it. (The flash itself wants a quick live confirm.)* +- [x] **W11 — No taskbar overlay badge** (deferred in roadmap): a download manager conventionally shows an active-count / error overlay icon via `setOverlayIcon`. **Standard:** add it (the progress bar's error - mode is not a substitute). + mode is not a substitute). *Done: [badge.ts](src/main/badge.ts) draws a green (active) / red (error) dot + `NativeImage` with pure Node/zlib (no dep), and the `taskbarProgress` handler in + [ipc.ts](src/main/ipc.ts) calls `win.setOverlayIcon(badge, label)` with an accessible count label + ("N downloads active" / "Download error"), cleared to `null` when the queue goes idle. (Numeric count + lives in the tooltip label — a 16px overlay can't legibly render digits.)* - [ ] **W12 — Fallback tray icon is single-resolution.** The embedded fallback is a 32×32 PNG, so on 150%/200% DPI the tray glyph is blurry when the real multi-size `.ico` is absent. **Standard:** a multi-size fallback (or rely only on the `.ico`). @@ -1769,7 +1782,7 @@ Security & correctness audit (2026-06-23): - Migrate JSON stores to better-sqlite3 if a user indexes many large channels. - Metadata editing (`--parse-metadata`/`--replace-in-metadata`) — held pending a live recipe. - PO-token WebView auto-minting — only the `--extractor-args` plumbing shipped. -- Taskbar overlay badge (needs a drawn `NativeImage`). +- ~~Taskbar overlay badge (needs a drawn `NativeImage`)~~ — done (W11): [badge.ts](src/main/badge.ts). - i18n — deliberately deferred (note: UI strings are hard-coded throughout). - Code signing — **non-goal**: no certificate will be purchased, so builds ship unsigned and the SmartScreen prompt is accepted. The build remains signing-ready via `CSC_LINK`/`CSC_KEY_PASSWORD` env vars if this ever diff --git a/src/main/download.ts b/src/main/download.ts index da70d93..92a20d7 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -106,6 +106,11 @@ function notify(wc: WebContents, title: string, body: string, incognito = false) } }) n.show() + // W10: flash the taskbar button for attention when the event happened in the + // background (window unfocused/minimized/hidden). Windows stops the flash on its + // own once the window gets focus, so there's no explicit stop to manage. + const flashWin = BrowserWindow.fromWebContents(wc) + if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true) } function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void { diff --git a/src/main/index.ts b/src/main/index.ts index 21c0e3e..1eaca5f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deepl import { isSyncLaunch } from './schedule' import { createTray, markQuitting, isQuitting } from './tray' import { logger } from './logger' +import { initialWindowState, saveWindowState } from './windowState' // 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 @@ -94,10 +95,16 @@ const DENIED_PERMISSIONS = new Set([ ]) function createWindow(): void { + // Reopen where the user left off (size / position / maximized), falling back to the + // default size when there's no usable saved state — first run, or the saved monitor + // is no longer connected (W2 / UX19). + const ws = initialWindowState() const win = new BrowserWindow({ title: 'AeroFetch', - width: 920, - height: 700, + width: ws.width, + height: ws.height, + x: ws.x, + y: ws.y, // Below this the 212px sidebar + content layout breaks; pin a sensible // floor so the window can't be dragged down to unusable widths (W1). minWidth: 640, @@ -114,6 +121,19 @@ function createWindow(): void { }) mainWindow = win + if (ws.maximized) win.maximize() + + // Persist size/position/maximized so the next launch restores them (W2). Debounced + // so a drag-resize writes once when it settles; also saved on close (which may only + // hide to tray) to capture the final state. + let saveTimer: ReturnType | null = null + const scheduleSave = (): void => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveWindowState(win), 500) + } + win.on('resize', scheduleSave) + win.on('move', scheduleSave) + // Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4). attachEditContextMenu(win.webContents) @@ -129,6 +149,8 @@ function createWindow(): void { // 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) => { + // Capture the final bounds even when the close only hides to tray (W2). + saveWindowState(win) if (isQuitting()) return const downloadsRunning = hasActiveDownloads() if (getSettings().minimizeToTray || downloadsRunning) { diff --git a/src/main/windowState.ts b/src/main/windowState.ts new file mode 100644 index 0000000..9af0540 --- /dev/null +++ b/src/main/windowState.ts @@ -0,0 +1,82 @@ +import { app, screen, type BrowserWindow, type Rectangle } from 'electron' +import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' +import { logger } from './logger' + +/** + * Persist and restore the main window's size / position / maximized state across + * launches (W2 / UX19) — Windows apps are expected to reopen where you left them. + * Stored as one small JSON object in `/window-state.json`; a disconnected + * monitor is handled by a visibility check so the window can never restore off-screen. + */ + +interface WindowState { + bounds: Rectangle + maximized: boolean +} + +// Matches the createWindow() defaults; used on first run and when a saved position +// is no longer on any connected display. +const DEFAULT_SIZE = { width: 920, height: 700 } + +function stateFile(): string { + return join(app.getPath('userData'), 'window-state.json') +} + +function readState(): WindowState | null { + try { + const s = JSON.parse(readFileSync(stateFile(), 'utf8')) as Partial + if (s?.bounds && typeof s.bounds.width === 'number' && typeof s.bounds.height === 'number') { + return { bounds: s.bounds as Rectangle, maximized: !!s.maximized } + } + } catch { + /* first run or corrupt — fall back to defaults */ + } + return null +} + +/** True when `b` overlaps the work area of some connected display (i.e. is reachable). */ +function isOnSomeDisplay(b: Rectangle): boolean { + return screen.getAllDisplays().some((d) => { + const wa = d.workArea + return ( + b.x < wa.x + wa.width && + b.x + b.width > wa.x && + b.y < wa.y + wa.height && + b.y + b.height > wa.y + ) + }) +} + +/** + * The bounds + maximized flag to open the window with. Returns just a size (Electron + * then centers it) when there's no usable saved position — first run, or the saved + * monitor is gone. + */ +export function initialWindowState(): { + width: number + height: number + x?: number + y?: number + maximized: boolean +} { + const saved = readState() + if (saved && isOnSomeDisplay(saved.bounds)) { + return { ...saved.bounds, maximized: saved.maximized } + } + return { ...DEFAULT_SIZE, maximized: saved?.maximized ?? false } +} + +/** + * Persist the window's current *normal* bounds (un-maximized) + maximized flag. + * Best-effort; a write failure is logged, never thrown. Callers should debounce. + */ +export function saveWindowState(win: BrowserWindow): void { + if (win.isDestroyed() || win.isMinimized()) return + try { + const state: WindowState = { bounds: win.getNormalBounds(), maximized: win.isMaximized() } + writeFileSync(stateFile(), JSON.stringify(state)) + } catch (e) { + logger.warn('failed to save window state', e) + } +}