From 4c6d42d059072eb43894e3656ff992ccf983ba0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 18:29:20 +0000 Subject: [PATCH 1/2] fix(overlay): seed display info from launch args instead of racing IPC createOverlayWindow pushed display-info on a once('did-finish-load') handler, but OverlayApp attaches its listener inside a useEffect. When the send landed first the message was dropped and displayRef stayed null for the lifetime of the window, so cursor positions fell through to the unmapped branch that treats global screen coordinates as display-local. That happens to be correct on a single display at origin (0,0), which is why it went unnoticed, and wrong everywhere else. Pass the display through webPreferences.additionalArguments so the preload can parse it synchronously and the renderer has its coordinate space before the first cursor-position message arrives. The IPC push stays for reloads, and is now `on` rather than `once` since the argv value is only correct for the first load. Refs #5 --- src/main/windows.ts | 27 ++++++++++++----- src/preload/index.ts | 41 ++++++++++++++++---------- src/renderer/components/OverlayApp.tsx | 8 +++-- src/shared/types.ts | 15 ++++++++++ 4 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index 56704ea..b182e9f 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, Display, screen } from 'electron'; import path from 'path'; -import type { StreamWindowBounds } from '../shared/types'; +import { DISPLAY_INFO_ARG_PREFIX, type DisplayInfo, type StreamWindowBounds } from '../shared/types'; const isDev = !app.isPackaged && process.env.VITE_DEV_SERVER === '1'; @@ -51,9 +51,18 @@ export function createPanelWindow(): BrowserWindow { return win; } +function toDisplayInfo(display: Display): DisplayInfo { + return { + id: display.id, + bounds: display.bounds, + scaleFactor: display.scaleFactor, + }; +} + /** A transparent, click-through overlay covering one display. */ export function createOverlayWindow(display: Display): BrowserWindow { const { x, y, width, height } = display.bounds; + const displayInfo = toDisplayInfo(display); const win = new BrowserWindow({ x, @@ -77,6 +86,10 @@ export function createOverlayWindow(display: Display): BrowserWindow { contextIsolation: true, nodeIntegration: false, sandbox: false, + // Hand the renderer its coordinate space up front. The IPC push + // below can land before React has attached its listener, so the + // overlay needs a value it can read synchronously on mount. + additionalArguments: [DISPLAY_INFO_ARG_PREFIX + JSON.stringify(displayInfo)], }, }); @@ -91,13 +104,11 @@ export function createOverlayWindow(display: Display): BrowserWindow { loadPage(win, 'overlay'); - // Pass display info to overlay so it knows its coordinate space - win.webContents.once('did-finish-load', () => { - win.webContents.send('display-info', { - id: display.id, - bounds: display.bounds, - scaleFactor: display.scaleFactor, - }); + // Re-push on every load so a reload (or a dev-server HMR full reload) + // refreshes the renderer's copy. `on`, not `once`: the argv value above + // is only correct for the first load. + win.webContents.on('did-finish-load', () => { + win.webContents.send('display-info', displayInfo); }); return win; diff --git a/src/preload/index.ts b/src/preload/index.ts index 94fb8a5..a88a8e6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron'; -import { IPC } from '../shared/types'; +import { IPC, DISPLAY_INFO_ARG_PREFIX } from '../shared/types'; import type { ApiKeyName, ClaudeModel, @@ -19,9 +19,25 @@ import type { LocalConnection, OllamaModelInfo, OllamaPullProgress, + DisplayInfo, } from '../shared/types'; import type { OllamaTestResult } from '../main/services/ollama-api'; +/** + * Display info handed to overlay windows via `additionalArguments`. + * Read once at preload time so the renderer never has to wait for IPC + * to learn its coordinate space. + */ +const initialDisplayInfo: DisplayInfo | null = (() => { + const arg = process.argv.find((a) => a.startsWith(DISPLAY_INFO_ARG_PREFIX)); + if (!arg) return null; + try { + return JSON.parse(arg.slice(DISPLAY_INFO_ARG_PREFIX.length)) as DisplayInfo; + } catch { + return null; + } +})(); + const api = { // ── Settings ─────────────────────────────────────────────────────── getSettings: (): Promise => ipcRenderer.invoke(IPC.GET_SETTINGS), @@ -188,21 +204,14 @@ const api = { // ── Audio Capture (overlay ↔ main) ────────────────────────────────── // ── Overlay / display info ──────────────────────────────────────── - onDisplayInfo: ( - cb: (info: { - id: number; - bounds: { x: number; y: number; width: number; height: number }; - scaleFactor: number; - }) => void, - ) => { - const handler = ( - _e: Electron.IpcRendererEvent, - info: { - id: number; - bounds: { x: number; y: number; width: number; height: number }; - scaleFactor: number; - }, - ) => cb(info); + /** + * The overlay's display, available synchronously on first paint. + * Null in windows that aren't overlays. + */ + getDisplayInfo: (): DisplayInfo | null => initialDisplayInfo, + + onDisplayInfo: (cb: (info: DisplayInfo) => void) => { + const handler = (_e: Electron.IpcRendererEvent, info: DisplayInfo) => cb(info); ipcRenderer.on('display-info', handler); return () => ipcRenderer.removeListener('display-info', handler); }, diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index 73fe5da..50c7cfe 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import type { VoiceState, DetectedElement } from '../../shared/types'; +import type { VoiceState, DetectedElement, DisplayInfo } from '../../shared/types'; import { Waveform } from './Waveform'; // Offset the companion cursor ~1/5 inch (≈19px at 96dpi) down-right @@ -32,7 +32,9 @@ export function OverlayApp() { const [cursorMode, setCursorMode] = useState('following'); const [companionPos, setCompanionPos] = useState({ x: 0, y: 0 }); const [isCursorOnThisDisplay, setIsCursorOnThisDisplay] = useState(false); - const displayRef = useRef<{ id: number; bounds: { x: number; y: number; width: number; height: number } } | null>(null); + // Seeded synchronously from the window's launch arguments so the first + // cursor-position message already has a coordinate space to map into. + const displayRef = useRef(window.flicky.getDisplayInfo()); const holdTimerRef = useRef | null>(null); const returnAnimRef = useRef(null); const cursorPosRef = useRef({ x: 0, y: 0 }); @@ -195,7 +197,7 @@ export function OverlayApp() { useEffect(() => { const unsubDisplayInfo = window.flicky.onDisplayInfo((info) => { - displayRef.current = { id: info.id, bounds: info.bounds }; + displayRef.current = info; }); const unsubs = [ diff --git a/src/shared/types.ts b/src/shared/types.ts index b20a7b7..05243af 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,6 +20,21 @@ export interface TranscriptionResult { isFinal: boolean; } +// ── Overlay / Displays ───────────────────────────────────────────────── + +export interface DisplayInfo { + id: number; + bounds: { x: number; y: number; width: number; height: number }; + scaleFactor: number; +} + +/** + * Prefix used to hand an overlay window its display info through + * `webPreferences.additionalArguments`, so the renderer can read it + * synchronously at startup instead of racing an IPC message. + */ +export const DISPLAY_INFO_ARG_PREFIX = '--flicky-display-info='; + // ── Screen Capture ───────────────────────────────────────────────────── export interface ScreenCapture { From 7830f6dffe84a35347fd44bd670c5458de4a7259 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 19:28:26 +0000 Subject: [PATCH 2/2] review: correct the did-finish-load rationale, warn on parse failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the argv value was only correct for the first load. It isn't: a reload re-executes the preload in the same process with the same argv, so the re-push sends an identical snapshot. It's redundancy, not an update path, and bounds changes don't flow through it at all — rebuildOverlays destroys and recreates the window instead. Say so. Also warn instead of silently returning null when the launch argument fails to parse, since that failure would otherwise reproduce the exact symptom this change exists to fix, and note why plain JSON in argv is safe here (all-numeric fields, so no spaces for Windows to split on). --- src/main/windows.ts | 13 ++++++++++--- src/preload/index.ts | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/windows.ts b/src/main/windows.ts index b182e9f..b416cf8 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -89,6 +89,11 @@ export function createOverlayWindow(display: Display): BrowserWindow { // Hand the renderer its coordinate space up front. The IPC push // below can land before React has attached its listener, so the // overlay needs a value it can read synchronously on mount. + // + // Safe as plain JSON only because every DisplayInfo field is + // numeric, so the serialized value can never contain a space. + // Windows splits additionalArguments on spaces — if this type ever + // grows a string field (a display label, say), base64 it first. additionalArguments: [DISPLAY_INFO_ARG_PREFIX + JSON.stringify(displayInfo)], }, }); @@ -104,9 +109,11 @@ export function createOverlayWindow(display: Display): BrowserWindow { loadPage(win, 'overlay'); - // Re-push on every load so a reload (or a dev-server HMR full reload) - // refreshes the renderer's copy. `on`, not `once`: the argv value above - // is only correct for the first load. + // Belt-and-braces: the preload already reads this same snapshot out of + // argv on every load, including reloads, so this send is redundant + // rather than an update path. It stays as a cheap safety net in case + // the argv read ever fails. Bounds changes do NOT arrive here — the + // window is destroyed and recreated by rebuildOverlays instead. win.webContents.on('did-finish-load', () => { win.webContents.send('display-info', displayInfo); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index a88a8e6..d0420e0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,7 +33,10 @@ const initialDisplayInfo: DisplayInfo | null = (() => { if (!arg) return null; try { return JSON.parse(arg.slice(DISPLAY_INFO_ARG_PREFIX.length)) as DisplayInfo; - } catch { + } catch (err) { + // Falling back to null silently would reproduce the exact bug this + // argument exists to fix, so make the failure visible. + console.warn('[Flicky] Could not parse display info from launch args:', err); return null; } })();