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:
2026-07-01 11:33:11 -04:00
parent 64af608596
commit ab825488b8
4 changed files with 132 additions and 10 deletions
+5
View File
@@ -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 {
+24 -2
View File
@@ -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<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).
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) {
+82
View File
@@ -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)
}
}