feat(win): W2/UX19 persist window placement, W10 taskbar flash; tick W11
- W2/UX19: windowState.ts persists the window's normal bounds + maximized flag to <userData>/window-state.json (no new dep); createWindow restores from it with a screen.getAllDisplays() visibility guard so it never reopens off a disconnected monitor. Debounced save on resize/move + save on close (which may hide to tray). - W10: notify() flashFrame(true)s the taskbar when a completion/failure lands while the window is unfocused/minimized (Windows clears the flash on focus). - W11: already implemented (badge.ts overlay dot + setOverlayIcon) — ticked. typecheck + 268 tests + eslint + prettier green. (Window restore + flash want a live quit/relaunch confirm.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+21
-8
@@ -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.
|
- [ ] **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" →
|
- [ ] **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.
|
"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
|
- [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.
|
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
|
- [ ] **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
|
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.
|
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`
|
- [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
|
([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).
|
(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.
|
**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`).
|
**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 `<userData>/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
|
- [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**
|
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
|
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
|
### 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
|
minimized/hidden, the app doesn't `flashFrame` the taskbar button. **Standard:** flash for attention on
|
||||||
background completion (pairs with the existing notification).
|
background completion (pairs with the existing notification). *Fixed: `notify()` in
|
||||||
- [ ] **W11 — No taskbar overlay badge** (deferred in roadmap): a download manager conventionally shows an
|
[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
|
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
|
- [ ] **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
|
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`).
|
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.
|
- 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.
|
- Metadata editing (`--parse-metadata`/`--replace-in-metadata`) — held pending a live recipe.
|
||||||
- PO-token WebView auto-minting — only the `--extractor-args` plumbing shipped.
|
- 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).
|
- 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
|
- 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
|
prompt is accepted. The build remains signing-ready via `CSC_LINK`/`CSC_KEY_PASSWORD` env vars if this ever
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ function notify(wc: WebContents, title: string, body: string, incognito = false)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
n.show()
|
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 {
|
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
|
||||||
|
|||||||
+24
-2
@@ -22,6 +22,7 @@ import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deepl
|
|||||||
import { isSyncLaunch } from './schedule'
|
import { isSyncLaunch } from './schedule'
|
||||||
import { createTray, markQuitting, isQuitting } from './tray'
|
import { createTray, markQuitting, isQuitting } from './tray'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
import { initialWindowState, saveWindowState } from './windowState'
|
||||||
|
|
||||||
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
// 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
|
// 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 {
|
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({
|
const win = new BrowserWindow({
|
||||||
title: 'AeroFetch',
|
title: 'AeroFetch',
|
||||||
width: 920,
|
width: ws.width,
|
||||||
height: 700,
|
height: ws.height,
|
||||||
|
x: ws.x,
|
||||||
|
y: ws.y,
|
||||||
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
||||||
// floor so the window can't be dragged down to unusable widths (W1).
|
// floor so the window can't be dragged down to unusable widths (W1).
|
||||||
minWidth: 640,
|
minWidth: 640,
|
||||||
@@ -114,6 +121,19 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
mainWindow = win
|
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<typeof setTimeout> | 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).
|
// Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4).
|
||||||
attachEditContextMenu(win.webContents)
|
attachEditContextMenu(win.webContents)
|
||||||
|
|
||||||
@@ -129,6 +149,8 @@ function createWindow(): void {
|
|||||||
// kill the spawned yt-dlp processes and lose the download. A real quit (tray
|
// 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.
|
// menu / before-quit) sets isQuitting() so this lets the close through.
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
|
// Capture the final bounds even when the close only hides to tray (W2).
|
||||||
|
saveWindowState(win)
|
||||||
if (isQuitting()) return
|
if (isQuitting()) return
|
||||||
const downloadsRunning = hasActiveDownloads()
|
const downloadsRunning = hasActiveDownloads()
|
||||||
if (getSettings().minimizeToTray || downloadsRunning) {
|
if (getSettings().minimizeToTray || downloadsRunning) {
|
||||||
|
|||||||
@@ -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 `<userData>/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<WindowState>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user