diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 2aa748b49..7e747140c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -222,7 +222,11 @@ interface Window { switchToEditor: () => Promise; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; - showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; + showSourceHighlight: ( + source: ProcessedDesktopSource, + options?: { activateWindow?: boolean }, + ) => Promise<{ success: boolean }>; + clearSourceHighlight: () => Promise<{ success: boolean }>; getSelectedSource: () => Promise; onSelectedSourceChanged: ( callback: (source: ProcessedDesktopSource | null) => void, diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 02fb3c754..15cc45848 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -86,6 +86,39 @@ export function getWindowBoundsFromNativeSource( return { x, y, width, height }; } +export function getVisibleWindowBoundsFromNativeSource( + source?: NativeMacWindowSource | null, +): WindowBounds | null { + if (!source || source.visible === false) { + return null; + } + + const { + visibleX: x, + visibleY: y, + visibleWidth: width, + visibleHeight: height, + } = source; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) + ) { + return getWindowBoundsFromNativeSource(source); + } + + if (width <= 0 || height <= 0) { + return null; + } + + return { x, y, width, height }; +} + export async function resolveMacWindowBounds(source: SelectedSource): Promise { const windowId = parseWindowId(source.id); if (!windowId) { @@ -101,6 +134,23 @@ export async function resolveMacWindowBounds(source: SelectedSource): Promise { + const windowId = parseWindowId(source.id); + if (!windowId) { + return null; + } + + try { + const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 0 }); + const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId); + return getVisibleWindowBoundsFromNativeSource(matchedSource); + } catch { + return null; + } +} + export function parseXwininfoBounds(stdout: string): WindowBounds | null { const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/); const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/); diff --git a/electron/ipc/paths/binaries.ts b/electron/ipc/paths/binaries.ts index 739f7df84..8e6575713 100644 --- a/electron/ipc/paths/binaries.ts +++ b/electron/ipc/paths/binaries.ts @@ -4,10 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { app } from "electron"; -import { - nativeHelperMigrationPromise, - setNativeHelperMigrationPromise, -} from "../state"; +import { nativeHelperMigrationPromise, setNativeHelperMigrationPromise } from "../state"; const execFileAsync = promisify(execFile); @@ -49,6 +46,20 @@ export function getPrebundledNativeHelperPath(binaryName: string): string { return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName); } +function getWindowsNativeArchTag(): string { + return process.arch === "arm64" ? "win32-arm64" : "win32-x64"; +} + +function getPrebundledWindowsNativeHelperPath(binaryName: string): string { + return resolveUnpackedAppPath( + "electron", + "native", + "bin", + getWindowsNativeArchTag(), + binaryName, + ); +} + export function resolvePreferredWindowsNativeHelperPath( helperDirectory: string, binaryName: string, @@ -61,7 +72,7 @@ export function resolvePreferredWindowsNativeHelperPath( "Release", binaryName, ); - const prebundledPath = getPrebundledNativeHelperPath(binaryName); + const prebundledPath = getPrebundledWindowsNativeHelperPath(binaryName); if (app.isPackaged && existsSync(prebundledPath)) { return prebundledPath; @@ -128,7 +139,11 @@ export function getCursorMonitorExePath(): string { async function migrateLegacyNativeHelperBinaries(): Promise { const legacyToCurrentPaths: Array<[string, string]> = [ [ - path.join(app.getPath("userData"), "native-tools", "openscreen-screencapturekit-helper"), + path.join( + app.getPath("userData"), + "native-tools", + "openscreen-screencapturekit-helper", + ), getNativeCaptureHelperBinaryPath(), ], [ diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index b13453c74..9f2604251 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -13,6 +13,7 @@ import { systemPreferences, } from "electron"; import { showCursor } from "../../cursorHider"; +import { clearSourceHighlightWindow } from "../../sourceHighlight"; import { getMonitorHandles } from "../monitorResolver"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; @@ -1827,6 +1828,7 @@ export function registerRecordingHandlers( startCursorSampling(); void startInteractionCapture(); } else { + clearSourceHighlightWindow(); setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 33c9ee74c..30ee77ac9 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -4,17 +4,19 @@ import { app, BrowserWindow, desktopCapturer, ipcMain } from "electron"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { selectedSource, setSelectedSource } from "../state"; import type { SelectedSource } from "../types"; -import { getScreen, parseWindowId } from "../utils"; +import { getScreen } from "../utils"; import { getDisplayBoundsForSource, getDisplayWorkAreaForSource } from "../recording/ffmpeg"; import { getScreenSourceIdForDisplay } from "./sourceMapping"; import { getNativeMacWindowSources, - resolveMacWindowBounds, + resolveMacWindowVisibleBounds, resolveWindowsWindowBounds, - resolveLinuxWindowBounds, stopWindowBoundsCapture, } from "../cursor/bounds"; -import { reassertHudOverlayMousePassthrough } from "../../windows"; +import { + clearSourceHighlightWindow, + showSourceHighlightWindow, +} from "../../sourceHighlight"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; @@ -26,10 +28,58 @@ let sourceListCache: } | null = null; +type SourceHighlightIpcOptions = { + activateWindow?: boolean; +}; + function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } +function intersectBounds( + first: { x: number; y: number; width: number; height: number }, + second: { x: number; y: number; width: number; height: number }, +) { + const x = Math.max(first.x, second.x); + const y = Math.max(first.y, second.y); + const right = Math.min(first.x + first.width, second.x + second.width); + const bottom = Math.min(first.y + first.height, second.y + second.height); + const width = right - x; + const height = bottom - y; + + return width > 0 && height > 0 ? { x, y, width, height } : null; +} + +function getVisibleWindowHighlightBounds(bounds: { + x: number; + y: number; + width: number; + height: number; +}) { + const intersections = getScreen() + .getAllDisplays() + .map((display) => intersectBounds(bounds, display.bounds)) + .filter((candidate): candidate is typeof bounds => candidate !== null); + + if (intersections.length === 0) { + return null; + } + + return intersections.reduce((combined, current) => { + const x = Math.min(combined.x, current.x); + const y = Math.min(combined.y, current.y); + const right = Math.max(combined.x + combined.width, current.x + current.width); + const bottom = Math.max(combined.y + combined.height, current.y + current.height); + + return { + x, + y, + width: right - x, + height: bottom - y, + }; + }); +} + function broadcastSelectedSourceChange() { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { @@ -316,227 +366,126 @@ export function registerSourceHandlers({ return selectedSource; }); - ipcMain.handle("show-source-highlight", async (_, source: SelectedSource) => { - try { - const isWindow = source.id?.startsWith("window:"); - const windowId = isWindow ? parseWindowId(source.id) : null; - - // ── 1. Bring window to front ── - if (isWindow && process.platform === "darwin") { - const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim(); - const appName = - rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) ? rawAppName : null; - if (appName) { - try { - await execFileAsync( - "osascript", - [ - "-e", - "on run argv", - "-e", - "tell application (item 1 of argv) to activate", - "-e", - "end run", - "--", - appName, - ], - { timeout: 2000 }, - ); - await new Promise((resolve) => setTimeout(resolve, 350)); - } catch { - /* ignore */ - } + ipcMain.handle( + "show-source-highlight", + async (_, source: SelectedSource, options?: SourceHighlightIpcOptions) => { + try { + const isWindow = source.id?.startsWith("window:"); + const shouldActivateWindow = options?.activateWindow !== false; + + if (process.platform === "linux") { + clearSourceHighlightWindow(); + return { success: false }; } - } else if (windowId && process.platform === "linux") { - try { - await execFileAsync("wmctrl", ["-i", "-a", `0x${windowId.toString(16)}`], { - timeout: 1500, - }); - } catch { - try { - await execFileAsync("xdotool", ["windowactivate", String(windowId)], { - timeout: 1500, - }); - } catch { - /* not available */ + + // ── 1. Bring window to front ── + if (shouldActivateWindow && isWindow && process.platform === "darwin") { + const rawAppName = source.appName || source.name?.split(" — ")[0]?.trim(); + const appName = + rawAppName && /^[\w .&()+'-]{1,64}$/.test(rawAppName) + ? rawAppName + : null; + if (appName) { + try { + await execFileAsync( + "osascript", + [ + "-e", + "on run argv", + "-e", + "tell application (item 1 of argv) to activate", + "-e", + "end run", + "--", + appName, + ], + { timeout: 2000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 350)); + } catch { + /* ignore */ + } } } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - // ── 2. Resolve bounds ── - let bounds: { x: number; y: number; width: number; height: number } | null = null; - - if (source.id?.startsWith("screen:")) { - bounds = - process.platform === "darwin" - ? getDisplayWorkAreaForSource(source) - : getDisplayBoundsForSource(source); - } else if (isWindow) { - if (process.platform === "darwin") { - bounds = await resolveMacWindowBounds(source); - } else if (process.platform === "win32") { - bounds = await resolveWindowsWindowBounds(source); - } else if (process.platform === "linux") { - bounds = await resolveLinuxWindowBounds(source); + // ── 2. Resolve bounds ── + let bounds: { x: number; y: number; width: number; height: number } | null = + null; + + if (source.id?.startsWith("screen:")) { + bounds = + process.platform === "darwin" + ? getDisplayWorkAreaForSource(source) + : getDisplayBoundsForSource(source); + } else if (isWindow) { + if (process.platform === "darwin") { + bounds = await resolveMacWindowVisibleBounds(source); + } else if (process.platform === "win32") { + bounds = await resolveWindowsWindowBounds(source); + } } - } - - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - bounds = getDisplayBoundsForSource(source); - } - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - const primaryBounds = getScreen().getPrimaryDisplay().bounds; - if (primaryBounds.width <= 0 || primaryBounds.height <= 0) { - return { success: false }; + if (isWindow) { + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + clearSourceHighlightWindow(); + return { success: false }; + } + const visibleBounds = getVisibleWindowHighlightBounds(bounds); + if (!visibleBounds) { + clearSourceHighlightWindow(); + return { success: false }; + } + bounds = visibleBounds; } - bounds = primaryBounds; - } - const resolvedBounds = bounds; - - // ── 3. Show traveling wave highlight ── - // On macOS, screen highlights use workArea and no outward padding — - // macOS clamps window positions below the menu bar so outward - // padding only works on the left/top while right/bottom run off-screen. - const isScreen = source.id?.startsWith("screen:"); - const isMacScreen = isScreen && process.platform === "darwin"; - const pad = isMacScreen ? 0 : 6; - const highlightWin = new BrowserWindow({ - x: resolvedBounds.x - pad, - y: resolvedBounds.y - pad, - width: resolvedBounds.width + pad * 2, - height: resolvedBounds.height + pad * 2, - frame: false, - transparent: true, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - resizable: false, - focusable: false, - webPreferences: { nodeIntegration: false, contextIsolation: true }, - }); - - highlightWin.setIgnoreMouseEvents(true); - - const borderRadius = isMacScreen ? 0 : 10; - const glowInset = isMacScreen ? 0 : -4; - const glowRadius = isMacScreen ? 0 : 14; - const glowPad = isMacScreen ? 3 : 6; - - const html = ` - -
-
-` + ipcMain.handle("clear-source-highlight", () => { + clearSourceHighlightWindow(); + return { success: true }; + }); - try { - await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) - } catch (loadError) { - if (!highlightWin.isDestroyed()) { - highlightWin.close() - } - throw loadError - } + ipcMain.handle("get-selected-source", () => { + return selectedSource; + }); - // The highlight window appearing (even with focusable:false) can corrupt - // the WS_EX_TRANSPARENT flag on the HUD on Windows 11+, breaking hover - // detection until the user moves their mouse over the bar again. - // Re-assert passthrough immediately so click-through is restored at once. - reassertHudOverlayMousePassthrough(); - - const highlightCloseTimer = setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) - - highlightWin.on("closed", () => { - clearTimeout(highlightCloseTimer); - // Re-assert once more when the window is actually destroyed so the - // native flag is clean regardless of timing. - reassertHudOverlayMousePassthrough(); - }); - - return { success: true } - } catch (error) { - console.error('Failed to show source highlight:', error) - return { success: false } - } - }) - - ipcMain.handle('get-selected-source', () => { - return selectedSource - }) - - ipcMain.handle('open-source-selector', () => { - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin) { - sourceSelectorWin.focus() - return - } - createSourceSelectorWindow() - }) - ipcMain.handle('switch-to-editor', () => { - console.log('[switch-to-editor] Opening editor window') - const sourceSelectorWin = getSourceSelectorWindow() - if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { - sourceSelectorWin.close() - } - createEditorWindow() - }) + ipcMain.handle("open-source-selector", () => { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.focus(); + return; + } + createSourceSelectorWindow(); + }); + ipcMain.handle("switch-to-editor", () => { + console.log("[switch-to-editor] Opening editor window"); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { + sourceSelectorWin.close(); + } + createEditorWindow(); + }); } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..6afcb4764 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -118,6 +118,11 @@ export type NativeMacWindowSource = { y?: number; width?: number; height?: number; + visible?: boolean; + visibleX?: number; + visibleY?: number; + visibleWidth?: number; + visibleHeight?: number; }; export type HookEventName = "mousedown" | "mouseup" | "mousemove"; diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb6..7f8515f2a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -27,6 +27,7 @@ import { } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; import { ensurePackagedRendererServer } from "./rendererServer"; +import { clearSourceHighlightWindow } from "./sourceHighlight"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -851,6 +852,7 @@ function createSourceSelectorWindowWrapper() { // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. app.on("before-quit", () => { + clearSourceHighlightWindow(); killWindowsCaptureProcess(); showCursor(); cleanupNativeVideoExportSessions(); @@ -917,6 +919,7 @@ app.whenReady().then(async () => { } ipcMain.on("hud-overlay-close", () => { + clearSourceHighlightWindow(); const hud = getHudOverlayWindow(); if (hud) { console.log("[main] Closing HUD window via hud-overlay-close"); diff --git a/electron/native/ScreenCaptureKitWindowList.swift b/electron/native/ScreenCaptureKitWindowList.swift index cdb2f5757..018d4464e 100644 --- a/electron/native/ScreenCaptureKitWindowList.swift +++ b/electron/native/ScreenCaptureKitWindowList.swift @@ -1,6 +1,7 @@ import Foundation import CoreGraphics import ScreenCaptureKit +import AppKit struct WindowListEntry: Codable { let id: String @@ -13,6 +14,38 @@ struct WindowListEntry: Codable { let y: Double let width: Double let height: Double + let visible: Bool + let visibleX: Double? + let visibleY: Double? + let visibleWidth: Double? + let visibleHeight: Double? +} + +struct WindowRect { + let x: Double + let y: Double + let width: Double + let height: Double + + var right: Double { x + width } + var bottom: Double { y + height } + + func intersection(_ other: WindowRect) -> WindowRect? { + let nextX = max(x, other.x) + let nextY = max(y, other.y) + let nextRight = min(right, other.right) + let nextBottom = min(bottom, other.bottom) + let nextWidth = nextRight - nextX + let nextHeight = nextBottom - nextY + return nextWidth > 0 && nextHeight > 0 + ? WindowRect(x: nextX, y: nextY, width: nextWidth, height: nextHeight) + : nil + } +} + +struct WindowVisibility { + let visible: Bool + let bounds: WindowRect? } func normalize(_ value: String?) -> String? { @@ -38,6 +71,168 @@ let excludedWindowTitles: Set = [ "Wallpaper-", ] +func rectFromCgWindowBounds(_ value: Any?) -> WindowRect? { + guard let dictionary = value as? NSDictionary else { + return nil + } + + var rect = CGRect.zero + if CGRectMakeWithDictionaryRepresentation(dictionary, &rect) { + return WindowRect( + x: Double(rect.origin.x), + y: Double(rect.origin.y), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + return nil +} + +func subtract(_ cutout: WindowRect, from source: WindowRect) -> [WindowRect] { + guard let overlap = source.intersection(cutout) else { + return [source] + } + + var pieces: [WindowRect] = [] + + if overlap.y > source.y { + pieces.append(WindowRect( + x: source.x, + y: source.y, + width: source.width, + height: overlap.y - source.y + )) + } + + if overlap.bottom < source.bottom { + pieces.append(WindowRect( + x: source.x, + y: overlap.bottom, + width: source.width, + height: source.bottom - overlap.bottom + )) + } + + if overlap.x > source.x { + pieces.append(WindowRect( + x: source.x, + y: overlap.y, + width: overlap.x - source.x, + height: overlap.height + )) + } + + if overlap.right < source.right { + pieces.append(WindowRect( + x: overlap.right, + y: overlap.y, + width: source.right - overlap.right, + height: overlap.height + )) + } + + return pieces.filter { $0.width > 0 && $0.height > 0 } +} + +func unionRect(_ rects: [WindowRect]) -> WindowRect? { + guard let first = rects.first else { + return nil + } + + var minX = first.x + var minY = first.y + var maxRight = first.right + var maxBottom = first.bottom + + for rect in rects.dropFirst() { + minX = min(minX, rect.x) + minY = min(minY, rect.y) + maxRight = max(maxRight, rect.right) + maxBottom = max(maxBottom, rect.bottom) + } + + return WindowRect(x: minX, y: minY, width: maxRight - minX, height: maxBottom - minY) +} + +func shouldIgnoreWindowStackEntry(_ entry: [String: Any]) -> Bool { + let layer = entry[kCGWindowLayer as String] as? Int ?? 0 + if layer != 0 { + return true + } + + let alpha = entry[kCGWindowAlpha as String] as? Double ?? 1 + if alpha <= 0 { + return true + } + + if let appName = normalize(entry[kCGWindowOwnerName as String] as? String), + appName == "Recordly" { + return true + } + + let ownerPid = entry[kCGWindowOwnerPID as String] as? pid_t + let bundleId = ownerPid.flatMap { pid in + normalize(NSRunningApplication(processIdentifier: pid)?.bundleIdentifier) + } + if let bundleId, excludedBundleIds.contains(bundleId) { + return true + } + + if let windowTitle = normalize(entry[kCGWindowName as String] as? String), + excludedWindowTitles.contains(windowTitle) { + return true + } + + guard let bounds = rectFromCgWindowBounds(entry[kCGWindowBounds as String]), + bounds.width >= 1, + bounds.height >= 1 else { + return true + } + + return false +} + +func buildVisibilityByWindowId() -> [UInt32: WindowVisibility] { + guard let windowInfo = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], + kCGNullWindowID + ) as? [[String: Any]] else { + return [:] + } + + var visibilityByWindowId: [UInt32: WindowVisibility] = [:] + var coveringRects: [WindowRect] = [] + + for entry in windowInfo { + guard let windowNumber = entry[kCGWindowNumber as String] as? UInt32, + let bounds = rectFromCgWindowBounds(entry[kCGWindowBounds as String]) else { + continue + } + + if shouldIgnoreWindowStackEntry(entry) { + continue + } + + var visibleRects = [bounds] + for cover in coveringRects { + visibleRects = visibleRects.flatMap { subtract(cover, from: $0) } + if visibleRects.isEmpty { + break + } + } + + visibilityByWindowId[windowNumber] = WindowVisibility( + visible: !visibleRects.isEmpty, + bounds: unionRect(visibleRects) + ) + + coveringRects.append(bounds) + } + + return visibilityByWindowId +} + // Force CoreGraphics Services initialization before asking ScreenCaptureKit for // shareable content. Without this, the helper can stall sporadically when run // as a standalone CLI process from Electron. @@ -49,6 +244,8 @@ group.enter() Task { do { let shareableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let visibilityByWindowId = buildVisibilityByWindowId() + let hasVisibilityStack = !visibilityByWindowId.isEmpty struct RawWindowEntry { let entry: WindowListEntry @@ -61,6 +258,8 @@ Task { let windowTitle = normalize(window.title) let bundleId = normalize(window.owningApplication?.bundleIdentifier) let frame = window.frame + let visibility = visibilityByWindowId[window.windowID] + let visibleBounds = visibility?.bounds guard window.windowLayer == 0 else { return nil @@ -104,7 +303,12 @@ Task { x: Double(frame.origin.x), y: Double(frame.origin.y), width: Double(frame.width), - height: Double(frame.height) + height: Double(frame.height), + visible: hasVisibilityStack ? (visibility?.visible ?? false) : true, + visibleX: visibleBounds?.x, + visibleY: visibleBounds?.y, + visibleWidth: visibleBounds?.width, + visibleHeight: visibleBounds?.height ) return RawWindowEntry(entry: entry, hasRawTitle: windowTitle != nil, bundleId: bundleId) diff --git a/electron/native/ScreenCaptureKitWindowListGeometry.test.ts b/electron/native/ScreenCaptureKitWindowListGeometry.test.ts new file mode 100644 index 000000000..e6cc004cd --- /dev/null +++ b/electron/native/ScreenCaptureKitWindowListGeometry.test.ts @@ -0,0 +1,135 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const describeOnDarwin = process.platform === "darwin" ? describe : describe.skip; + +function sliceRequired(source: string, startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf(endMarker, start); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describeOnDarwin("ScreenCaptureKitWindowList geometry", () => { + it("keeps subtract and unionRect behavior stable", () => { + const swiftcCheck = spawnSync("swiftc", ["--version"], { encoding: "utf8" }); + if (swiftcCheck.status !== 0) { + console.warn("Skipping Swift geometry test because swiftc is unavailable."); + return; + } + + const sourcePath = path.join( + process.cwd(), + "electron", + "native", + "ScreenCaptureKitWindowList.swift", + ); + const source = readFileSync(sourcePath, "utf8"); + const windowRectDeclaration = sliceRequired( + source, + "struct WindowRect", + "struct WindowVisibility", + ); + const geometryDeclarations = sliceRequired( + source, + "func subtract", + "func shouldIgnoreWindowStackEntry", + ); + + const tempDir = mkdtempSync(path.join(os.tmpdir(), "recordly-window-geometry-")); + try { + const testPath = path.join(tempDir, "GeometryTest.swift"); + const outputPath = path.join(tempDir, "GeometryTest"); + writeFileSync( + testPath, + ` +${windowRectDeclaration} +${geometryDeclarations} + +extension WindowRect: Equatable {} + +func describe(_ rect: WindowRect) -> String { + return "(x: \\(rect.x), y: \\(rect.y), width: \\(rect.width), height: \\(rect.height))" +} + +func expectRect(_ actual: WindowRect, _ expected: WindowRect, _ label: String) { + if actual != expected { + fatalError("\\(label): expected \\(describe(expected)), got \\(describe(actual))") + } +} + +func expectRects(_ actual: [WindowRect], _ expected: [WindowRect], _ label: String) { + if actual.count != expected.count { + fatalError("\\(label): expected \\(expected.count) pieces, got \\(actual.count)") + } + for (index, pair) in zip(actual, expected).enumerated() { + expectRect(pair.0, pair.1, "\\(label)[\\(index)]") + } +} + +let source = WindowRect(x: 0, y: 0, width: 10, height: 10) + +expectRects( + subtract(WindowRect(x: 10, y: 0, width: 5, height: 10), from: source), + [source], + "touching edges" +) + +expectRects( + subtract(WindowRect(x: -5, y: -5, width: 20, height: 20), from: source), + [], + "full occlusion" +) + +let nestedPieces = [ + WindowRect(x: 0, y: 0, width: 10, height: 2), + WindowRect(x: 0, y: 6, width: 10, height: 4), + WindowRect(x: 0, y: 2, width: 2, height: 4), + WindowRect(x: 6, y: 2, width: 4, height: 4), +] +expectRects( + subtract(WindowRect(x: 2, y: 2, width: 4, height: 4), from: source), + nestedPieces, + "nested occluder" +) +expectRect(unionRect(nestedPieces)!, source, "nested occluder union") + +expectRect( + unionRect([ + WindowRect(x: 0, y: 0, width: 5, height: 5), + WindowRect(x: 10, y: 5, width: 5, height: 5), + ])!, + WindowRect(x: 0, y: 0, width: 15, height: 10), + "disjoint visible region union" +) + +if unionRect([]) != nil { + fatalError("empty union should be nil") +} +`, + "utf8", + ); + + const compile = spawnSync("swiftc", [testPath, "-o", outputPath], { + encoding: "utf8", + }); + if (compile.status !== 0) { + const output = [compile.stdout, compile.stderr].filter(Boolean).join("\n"); + if (output) { + console.error(output); + } + } + expect(compile.status).toBe(0); + + const run = spawnSync(outputPath, { encoding: "utf8" }); + expect([run.stdout, run.stderr].filter(Boolean).join("\n")).toBe(""); + expect(run.status).toBe(0); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/electron/native/bin/darwin-arm64/recordly-window-list b/electron/native/bin/darwin-arm64/recordly-window-list index 76a7dab4a..3189bf315 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-window-list and b/electron/native/bin/darwin-arm64/recordly-window-list differ diff --git a/electron/native/bin/darwin-x64/recordly-window-list b/electron/native/bin/darwin-x64/recordly-window-list index e165257ae..4428184b7 100755 Binary files a/electron/native/bin/darwin-x64/recordly-window-list and b/electron/native/bin/darwin-x64/recordly-window-list differ diff --git a/electron/preload.ts b/electron/preload.ts index 9a15edb99..9c634afdc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -485,8 +485,14 @@ contextBridge.exposeInMainWorld("electronAPI", { selectSource: (source: ProcessedDesktopSource) => { return ipcRenderer.invoke("select-source", source); }, - showSourceHighlight: (source: ProcessedDesktopSource) => { - return ipcRenderer.invoke("show-source-highlight", source); + showSourceHighlight: ( + source: ProcessedDesktopSource, + options?: { activateWindow?: boolean }, + ) => { + return ipcRenderer.invoke("show-source-highlight", source, options); + }, + clearSourceHighlight: () => { + return ipcRenderer.invoke("clear-source-highlight"); }, getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); diff --git a/electron/sourceHighlight.ts b/electron/sourceHighlight.ts new file mode 100644 index 000000000..e1ca9b8a2 --- /dev/null +++ b/electron/sourceHighlight.ts @@ -0,0 +1,46 @@ +import { BrowserWindow } from "electron"; +import type { WindowBounds } from "./ipc/types"; +import { + createSourceHighlightController, + type SourceHighlightWindowBoundsOptions, +} from "./sourceHighlightController"; +import { reassertHudOverlayMousePassthrough } from "./windows"; + +const sourceHighlightController = createSourceHighlightController({ + createWindow: (bounds: WindowBounds) => + new BrowserWindow({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + frame: false, + transparent: true, + backgroundColor: "#00000000", + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + focusable: false, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }), + reassertHudOverlayMousePassthrough, +}); + +export function showSourceHighlightWindow( + bounds: WindowBounds, + options?: SourceHighlightWindowBoundsOptions, +): Promise { + return sourceHighlightController.show(bounds, options); +} + +export function clearSourceHighlightWindow(): void { + sourceHighlightController.clear(); +} diff --git a/electron/sourceHighlightController.test.ts b/electron/sourceHighlightController.test.ts new file mode 100644 index 000000000..2c6ca1cde --- /dev/null +++ b/electron/sourceHighlightController.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WindowBounds } from "./ipc/types"; +import { + createSourceHighlightController, + getSourceHighlightWindowBounds, +} from "./sourceHighlightController"; + +function createMockHighlightWindow() { + return { + close: vi.fn(), + isDestroyed: vi.fn(() => false), + loadURL: vi.fn(async () => undefined), + moveTop: vi.fn(), + on: vi.fn(), + setAlwaysOnTop: vi.fn(), + setBounds: vi.fn(), + setContentProtection: vi.fn(), + setIgnoreMouseEvents: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + showInactive: vi.fn(), + }; +} + +describe("getSourceHighlightWindowBounds", () => { + it("adds a small outer pad for window targets", () => { + expect( + getSourceHighlightWindowBounds({ + x: 100, + y: 200, + width: 640, + height: 360, + }), + ).toEqual({ + x: 94, + y: 194, + width: 652, + height: 372, + }); + }); + + it("does not push macOS screen highlights under the menu bar", () => { + const bounds: WindowBounds = { + x: 0, + y: 25, + width: 1440, + height: 875, + }; + + expect(getSourceHighlightWindowBounds(bounds, { isMacScreen: true })).toEqual(bounds); + }); +}); + +describe("createSourceHighlightController", () => { + it("creates a capture-protected click-through green outline window", async () => { + const highlightWindow = createMockHighlightWindow(); + const controller = createSourceHighlightController({ + createWindow: vi.fn(() => highlightWindow), + reassertHudOverlayMousePassthrough: vi.fn(), + }); + + await expect( + controller.show({ + x: 10, + y: 20, + width: 300, + height: 200, + }), + ).resolves.toBe(true); + + expect(highlightWindow.setContentProtection).toHaveBeenCalledWith(true); + expect(highlightWindow.setIgnoreMouseEvents).toHaveBeenCalledWith(true); + expect(highlightWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { + visibleOnFullScreen: true, + }); + const loadedUrl = String(highlightWindow.loadURL.mock.calls[0]?.[0]); + const loadedHtml = Buffer.from(loadedUrl.split(",")[1] ?? "", "base64").toString("utf8"); + expect(loadedHtml).toContain("#00d166"); + expect(loadedHtml).toContain("border-radius:22px"); + }); + + it("redraws hover changes by moving the existing outline instead of replacing it", async () => { + const highlightWindow = createMockHighlightWindow(); + const createWindow = vi.fn(() => highlightWindow); + const controller = createSourceHighlightController({ + createWindow, + reassertHudOverlayMousePassthrough: vi.fn(), + }); + + await controller.show({ x: 0, y: 0, width: 200, height: 100 }); + await controller.show({ x: 300, y: 120, width: 500, height: 280 }); + + expect(createWindow).toHaveBeenCalledTimes(1); + expect(highlightWindow.setBounds).toHaveBeenCalledWith( + { + x: 294, + y: 114, + width: 512, + height: 292, + }, + false, + ); + expect(highlightWindow.close).not.toHaveBeenCalled(); + }); + + it("clears the persistent recording outline explicitly", async () => { + const highlightWindow = createMockHighlightWindow(); + const reassertHudOverlayMousePassthrough = vi.fn(); + const controller = createSourceHighlightController({ + createWindow: vi.fn(() => highlightWindow), + reassertHudOverlayMousePassthrough, + }); + + await controller.show({ x: 0, y: 0, width: 200, height: 100 }); + reassertHudOverlayMousePassthrough.mockClear(); + controller.clear(); + + expect(highlightWindow.close).toHaveBeenCalledTimes(1); + expect(reassertHudOverlayMousePassthrough).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/sourceHighlightController.ts b/electron/sourceHighlightController.ts new file mode 100644 index 000000000..1575a84c0 --- /dev/null +++ b/electron/sourceHighlightController.ts @@ -0,0 +1,178 @@ +import { Buffer } from "node:buffer"; +import type { WindowBounds } from "./ipc/types"; + +const SOURCE_HIGHLIGHT_PAD = 6; +const SOURCE_HIGHLIGHT_COLOR = "#00d166"; +const SOURCE_HIGHLIGHT_INSET = 3; +const SOURCE_HIGHLIGHT_RADIUS = 22; + +type SourceHighlightAlwaysOnTopLevel = + | "normal" + | "status" + | "floating" + | "torn-off-menu" + | "modal-panel" + | "main-menu" + | "pop-up-menu" + | "screen-saver" + | "dock"; + +export type SourceHighlightWindowBoundsOptions = { + isMacScreen?: boolean; +}; + +export interface SourceHighlightWindowLike { + close: () => void; + isDestroyed: () => boolean; + loadURL: (url: string) => Promise; + moveTop?: () => void; + on?: (event: "closed", listener: () => void) => void; + setAlwaysOnTop?: (flag: boolean, level?: SourceHighlightAlwaysOnTopLevel) => void; + setBounds: (bounds: WindowBounds, animate?: boolean) => void; + setContentProtection?: (enable: boolean) => void; + setIgnoreMouseEvents?: (ignore: boolean) => void; + setVisibleOnAllWorkspaces?: ( + visible: boolean, + options?: { visibleOnFullScreen?: boolean }, + ) => void; + showInactive?: () => void; +} + +export type SourceHighlightControllerDependencies = { + createWindow: (bounds: WindowBounds) => SourceHighlightWindowLike; + reassertHudOverlayMousePassthrough: () => void; +}; + +export type SourceHighlightController = { + clear: () => void; + show: (bounds: WindowBounds, options?: SourceHighlightWindowBoundsOptions) => Promise; +}; + +export function getSourceHighlightWindowBounds( + bounds: WindowBounds, + options: SourceHighlightWindowBoundsOptions = {}, +): WindowBounds { + const pad = options.isMacScreen ? 0 : SOURCE_HIGHLIGHT_PAD; + + return { + x: Math.round(bounds.x - pad), + y: Math.round(bounds.y - pad), + width: Math.round(bounds.width + pad * 2), + height: Math.round(bounds.height + pad * 2), + }; +} + +export function createSourceHighlightHtml(): string { + return ` + + + + + + +
+ +`; +} + +function createDataUrl(html: string): string { + return `data:text/html;charset=utf-8;base64,${Buffer.from(html, "utf8").toString("base64")}`; +} + +function isUsableBounds(bounds: WindowBounds): boolean { + return ( + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0 + ); +} + +export function createSourceHighlightController({ + createWindow, + reassertHudOverlayMousePassthrough, +}: SourceHighlightControllerDependencies): SourceHighlightController { + let highlightWindow: SourceHighlightWindowLike | null = null; + + const getActiveWindow = () => { + if (highlightWindow && !highlightWindow.isDestroyed()) { + return highlightWindow; + } + highlightWindow = null; + return null; + }; + + const clear = () => { + const window = getActiveWindow(); + highlightWindow = null; + if (window) { + window.close(); + reassertHudOverlayMousePassthrough(); + } + }; + + const show = async ( + targetBounds: WindowBounds, + options: SourceHighlightWindowBoundsOptions = {}, + ) => { + if (!isUsableBounds(targetBounds)) { + clear(); + return false; + } + + const windowBounds = getSourceHighlightWindowBounds(targetBounds, options); + const activeWindow = getActiveWindow(); + if (activeWindow) { + activeWindow.setBounds(windowBounds, false); + activeWindow.showInactive?.(); + activeWindow.moveTop?.(); + reassertHudOverlayMousePassthrough(); + return true; + } + + const window = createWindow(windowBounds); + highlightWindow = window; + window.setContentProtection?.(true); + window.setIgnoreMouseEvents?.(true); + window.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true }); + window.setAlwaysOnTop?.(true, "screen-saver"); + window.on?.("closed", () => { + if (highlightWindow === window) { + highlightWindow = null; + } + reassertHudOverlayMousePassthrough(); + }); + + try { + await window.loadURL(createDataUrl(createSourceHighlightHtml())); + } catch (error) { + if (highlightWindow !== window) { + return false; + } + if (!window.isDestroyed()) { + window.close(); + } + throw error; + } + + window.showInactive?.(); + window.moveTop?.(); + reassertHudOverlayMousePassthrough(); + return true; + }; + + return { clear, show }; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d7dc51bc4..4e978609c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -176,26 +176,54 @@ function LaunchWindowContent() { useEffect(() => { let mounted = true; + const canShowSourceHighlight = platform !== null && platform !== "linux"; + const showSelectedSourceHighlight = (source: ProcessedDesktopSource) => { + const highlightPromise = window.electronAPI.showSourceHighlight?.(source, { + activateWindow: false, + }); + if (highlightPromise) { + void highlightPromise.catch((error) => { + console.warn("Failed to show selected source highlight:", error); + }); + } + }; void window.electronAPI.getSelectedSource().then((source) => { - if (mounted) syncSelectedSource(source); + if (!mounted) return; + syncSelectedSource(source); + if (source && canShowSourceHighlight) { + showSelectedSourceHighlight(source); + } else { + void window.electronAPI.clearSourceHighlight?.(); + } }); const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { - if (mounted) syncSelectedSource(source); + if (!mounted) return; + syncSelectedSource(source); + if (source && canShowSourceHighlight) { + showSelectedSourceHighlight(source); + } else { + void window.electronAPI.clearSourceHighlight?.(); + } }); return () => { mounted = false; cleanup?.(); }; - }, [syncSelectedSource]); + }, [platform, syncSelectedSource]); const hudStateTransition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const, }; + const hideIdleHud = () => { + void window.electronAPI?.clearSourceHighlight?.(); + window.electronAPI?.hudOverlayHide?.(); + }; + const recordingControls = ( window.electronAPI?.hudOverlayHide?.()} + onClick={hideIdleHud} title={t("recording.hideHud")} > diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 158b53e16..ac4fc3f68 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -26,6 +26,10 @@ interface SourceSelectorProps { loading?: boolean; /** Callback when a source is selected */ onSourceSelect?: (source: DesktopSource) => void; + /** Callback when a source option is previewed */ + onSourceHover?: (source: DesktopSource) => void; + /** Callback when source option preview ends */ + onSourceHoverEnd?: () => void; /** Callback to fetch sources */ onFetchSources?: () => Promise; /** Whether the popover is open */ @@ -81,7 +85,18 @@ export const SourceSelectorContent = ({ selectedSource = "Screen", loading = false, onSourceSelect = () => {}, -}: Pick) => { + onSourceHover, + onSourceHoverEnd, +}: Pick< + SourceSelectorProps, + | "screenSources" + | "windowSources" + | "selectedSource" + | "loading" + | "onSourceSelect" + | "onSourceHover" + | "onSourceHoverEnd" +>) => { const t = useScopedT("launch"); const renderSourceItem = (source: DesktopSource, index: number) => { const isSelected = selectedSource === source.name; @@ -94,6 +109,8 @@ export const SourceSelectorContent = ({ isSelected && "source-selector-item-selected", )} onClick={() => onSourceSelect(source)} + onFocus={() => onSourceHover?.(source)} + onPointerEnter={() => onSourceHover?.(source)} >
{source.thumbnail ? ( @@ -139,7 +156,10 @@ export const SourceSelectorContent = ({ } return ( -
+
{hasAnySources ? ( <> {screenSources.length > 0 ? ( @@ -190,6 +210,8 @@ export const SourceSelector = React.memo(function SourceSelector({ selectedSource: propsSelectedSource, loading: propsLoading, onSourceSelect: propsOnSourceSelect, + onSourceHover: propsOnSourceHover, + onSourceHoverEnd: propsOnSourceHoverEnd, onFetchSources: propsOnFetchSources, open: propsOpen, onOpenChange: propsOnOpenChange, @@ -365,6 +387,8 @@ export const SourceSelector = React.memo(function SourceSelector({ selectedSource={selectedSource} loading={loading} onSourceSelect={onSourceSelect} + onSourceHover={propsOnSourceHover} + onSourceHoverEnd={propsOnSourceHoverEnd} /> diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 7dba168e7..c663079eb 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -11,11 +11,42 @@ export function useLaunchWindowActions() { await window.electronAPI.selectSource(source); setSelectedSource(source.name); setHasSelectedSource(true); - window.electronAPI.showSourceHighlight?.({ + + try { + const platform = await window.electronAPI.getPlatform(); + if (platform === "linux") { + const clearPromise = window.electronAPI.clearSourceHighlight?.(); + if (clearPromise) { + void clearPromise.catch((error) => { + console.warn("Failed to clear Linux source highlight:", error); + }); + } + return; + } + } catch (error) { + console.warn("Failed to check platform before source highlight:", error); + const clearPromise = window.electronAPI.clearSourceHighlight?.(); + if (clearPromise) { + void clearPromise.catch((clearError) => { + console.warn( + "Failed to clear source highlight after platform check failed:", + clearError, + ); + }); + } + return; + } + + const highlightPromise = window.electronAPI.showSourceHighlight?.({ ...source, name: source.appName ? `${source.appName} — ${source.name}` : source.name, appName: source.appName, }); + if (highlightPromise) { + void highlightPromise.catch((error) => { + console.warn("Failed to show selected source highlight:", error); + }); + } }, []); const openVideoFile = useCallback(async () => { diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx index 5459fae14..e7a051e2a 100644 --- a/src/components/launch/popovers/SourcePopover.tsx +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -1,15 +1,24 @@ -import { useCallback, useMemo, type ReactNode, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SourceSelector } from "../SourceSelector"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { - mapRawSource, + type DesktopSource, isScreenSource, isWindowSource, - type DesktopSource, + mapRawSource, } from "./launchPopoverTypes"; +import { createSourcePreviewRequestGate } from "./sourcePreviewRequestGate"; const POPOVER_ID = "sources"; +function getHighlightSource(source: DesktopSource): DesktopSource { + return { + ...source, + name: source.appName ? `${source.appName} — ${source.name}` : source.name, + appName: source.appName, + }; +} + export function SourcePopover({ trigger, selectedSource, @@ -25,6 +34,8 @@ export function SourcePopover({ const [sources, setSources] = useState([]); const [loading, setLoading] = useState(false); const open = isOpen(POPOVER_ID); + const wasOpenRef = useRef(open); + const previewRequestGate = useMemo(() => createSourcePreviewRequestGate(), []); const fetchSources = useCallback(async () => { if (!window.electronAPI) return; @@ -45,6 +56,95 @@ export function SourcePopover({ const screenSources = useMemo(() => sources.filter(isScreenSource), [sources]); const windowSources = useMemo(() => sources.filter(isWindowSource), [sources]); + const clearSourceHighlight = useCallback((context: string) => { + const clearPromise = window.electronAPI?.clearSourceHighlight?.(); + if (clearPromise) { + void clearPromise.catch((error) => { + console.warn(`Failed to clear source highlight ${context}:`, error); + }); + } + }, []); + const showSourcePreview = useCallback( + (source: DesktopSource) => { + const requestId = previewRequestGate.next(); + void (async () => { + const platform = await window.electronAPI.getPlatform(); + if (!previewRequestGate.isCurrent(requestId)) { + return; + } + if (platform === "linux") { + clearSourceHighlight("for Linux preview"); + return; + } + const result = await window.electronAPI?.showSourceHighlight?.( + getHighlightSource(source), + { + activateWindow: false, + }, + ); + if (!previewRequestGate.isCurrent(requestId)) { + return; + } + if (result && !result.success) { + clearSourceHighlight("after preview failed"); + } + })().catch((error) => { + console.warn("Failed to preview source highlight:", error); + if (previewRequestGate.isCurrent(requestId)) { + clearSourceHighlight("after preview failed"); + } + }); + }, + [clearSourceHighlight, previewRequestGate], + ); + const restoreSelectedSourcePreview = useCallback(() => { + const requestId = previewRequestGate.next(); + void (async () => { + const platform = await window.electronAPI.getPlatform(); + if (!previewRequestGate.isCurrent(requestId)) { + return; + } + if (platform === "linux") { + clearSourceHighlight("for Linux restore"); + return; + } + const selected = await window.electronAPI?.getSelectedSource?.(); + if (!previewRequestGate.isCurrent(requestId)) { + return; + } + if (selected) { + const result = await window.electronAPI?.showSourceHighlight?.(selected, { + activateWindow: false, + }); + if (!previewRequestGate.isCurrent(requestId)) { + return; + } + if (result && !result.success) { + clearSourceHighlight("after restore failed"); + } + return; + } + clearSourceHighlight("with no selected source"); + })().catch((error) => { + console.error("Failed to restore source highlight:", error); + if (previewRequestGate.isCurrent(requestId)) { + clearSourceHighlight("after restore failed"); + } + }); + }, [clearSourceHighlight, previewRequestGate]); + + useEffect(() => { + if (wasOpenRef.current && !open) { + restoreSelectedSourcePreview(); + } + wasOpenRef.current = open; + }, [open, restoreSelectedSourcePreview]); + + useEffect(() => { + return () => { + restoreSelectedSourcePreview(); + }; + }, [restoreSelectedSourcePreview]); return ( { try { await onSourceSelect(source); diff --git a/src/components/launch/popovers/sourcePreviewRequestGate.test.ts b/src/components/launch/popovers/sourcePreviewRequestGate.test.ts new file mode 100644 index 000000000..f6ee5f076 --- /dev/null +++ b/src/components/launch/popovers/sourcePreviewRequestGate.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { createSourcePreviewRequestGate } from "./sourcePreviewRequestGate"; + +describe("createSourcePreviewRequestGate", () => { + it("marks an earlier restore request stale after a newer hover starts", () => { + const gate = createSourcePreviewRequestGate(); + + const restoreRequestId = gate.next(); + const hoverRequestId = gate.next(); + + expect(gate.isCurrent(restoreRequestId)).toBe(false); + expect(gate.isCurrent(hoverRequestId)).toBe(true); + }); +}); diff --git a/src/components/launch/popovers/sourcePreviewRequestGate.ts b/src/components/launch/popovers/sourcePreviewRequestGate.ts new file mode 100644 index 000000000..2ed6b1535 --- /dev/null +++ b/src/components/launch/popovers/sourcePreviewRequestGate.ts @@ -0,0 +1,13 @@ +export function createSourcePreviewRequestGate() { + let currentRequestId = 0; + + return { + next() { + currentRequestId += 1; + return currentRequestId; + }, + isCurrent(requestId: number) { + return requestId === currentRequestId; + }, + }; +} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 4c739f1c1..ea05346a0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -169,7 +169,6 @@ import { import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil, - scalePreviewBorderRadius, } from "./videoPlayback/layoutUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..40bd46dea 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -376,6 +376,24 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const requestedBrowserMicrophoneProfile = useRef(null); const hideEditorOverlayCursorByDefault = useRef(false); + const showRecordingSourceHighlight = useCallback( + (source: ProcessedDesktopSource, platform: string) => { + if (platform === "linux") { + return; + } + + const highlightPromise = window.electronAPI?.showSourceHighlight?.(source, { + activateWindow: false, + }); + if (highlightPromise) { + void highlightPromise.catch((error) => { + console.warn("Failed to show recording source highlight:", error); + }); + } + }, + [], + ); + const notifyRecordingFinalizationFailure = useCallback(async (message: string) => { setFinalizing(false); toast.error(message, { duration: 10000 }); @@ -1501,6 +1519,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; + showRecordingSourceHighlight(selectedSource, platform); // When native mic capture is unavailable or explicitly bypassed, // record mic via browser getUserMedia as a sidecar file. @@ -1907,6 +1926,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; recorder.start(RECORDER_TIMESLICE_MS); + showRecordingSourceHighlight(selectedSource, platform); setRecording(true); try { await window.electronAPI?.setRecordingState(true);