diff --git a/src/main/windows.ts b/src/main/windows.ts index 56704ea..b416cf8 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,15 @@ 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. + // + // 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)], }, }); @@ -91,13 +109,13 @@ 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, - }); + // 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); }); return win; diff --git a/src/preload/index.ts b/src/preload/index.ts index 94fb8a5..d0420e0 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,28 @@ 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 (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; + } +})(); + const api = { // ── Settings ─────────────────────────────────────────────────────── getSettings: (): Promise => ipcRenderer.invoke(IPC.GET_SETTINGS), @@ -188,21 +207,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 {