From c0deb0341498299f123b3308849f4a36ff7f165f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 5 May 2026 19:24:32 +0200 Subject: [PATCH] fix: gate Windows cursor settings --- .../windowsNativeRecordingSession.script.ts | 66 +++++++++++++++++- scripts/test-windows-native-cursor.mjs | 67 ++++++++++++++++++- src/components/video-editor/VideoEditor.tsx | 26 +++++++ src/lib/cursor/nativeCursor.ts | 36 +++++++++- src/native/contracts.ts | 2 + 5 files changed, 192 insertions(+), 5 deletions(-) diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts index 2ad9bbe..f97105e 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.script.ts @@ -111,6 +111,62 @@ function Write-JsonLine($payload) { [Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6)) } +function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { + if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) { + return $null + } + + if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or + $hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) { + return $null + } + + $opaquePixels = 0 + $topHalfOpaquePixels = 0 + $left = $bitmap.Width + $top = $bitmap.Height + $right = -1 + $bottom = -1 + + for ($y = 0; $y -lt $bitmap.Height; $y++) { + for ($x = 0; $x -lt $bitmap.Width; $x++) { + if ($bitmap.GetPixel($x, $y).A -le 32) { + continue + } + + $opaquePixels += 1 + if ($y -lt ($bitmap.Height / 2)) { + $topHalfOpaquePixels += 1 + } + if ($x -lt $left) { $left = $x } + if ($x -gt $right) { $right = $x } + if ($y -lt $top) { $top = $y } + if ($y -gt $bottom) { $bottom = $y } + } + } + + if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) { + return $null + } + + $opaqueWidth = $right - $left + 1 + $opaqueHeight = $bottom - $top + 1 + if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or + $opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) { + return $null + } + + if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) { + return $null + } + + if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) { + return 'closed-hand' + } + + return 'open-hand' +} + function Get-TargetBounds() { if ([string]::IsNullOrWhiteSpace($targetWindowHandle)) { return $null @@ -164,6 +220,9 @@ function Get-CursorAsset($cursorHandle, $cursorId) { try { $graphics.Clear([System.Drawing.Color]::Transparent) $graphics.DrawIcon($icon, 0, 0) + $hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } + $hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } + $customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY $bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png) $base64 = [System.Convert]::ToBase64String($memoryStream.ToArray()) @@ -172,8 +231,9 @@ function Get-CursorAsset($cursorHandle, $cursorId) { imageDataUrl = "data:image/png;base64,$base64" width = $bitmap.Width height = $bitmap.Height - hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } - hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } + hotspotX = $hotspotX + hotspotY = $hotspotY + cursorType = $customCursorType } } finally { @@ -218,6 +278,8 @@ while ($true) { $asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId if ($asset -and $cursorType) { $asset.cursorType = $cursorType + } elseif ($asset -and $asset.cursorType) { + $cursorType = $asset.cursorType } $lastCursorId = $cursorId } diff --git a/scripts/test-windows-native-cursor.mjs b/scripts/test-windows-native-cursor.mjs index 2a8b34c..7d7ea45 100644 --- a/scripts/test-windows-native-cursor.mjs +++ b/scripts/test-windows-native-cursor.mjs @@ -195,6 +195,62 @@ function Write-JsonLine($payload) { [Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress -Depth 6)) } +function Get-CustomCursorType($bitmap, $hotspotX, $hotspotY) { + if ($bitmap.Width -lt 24 -or $bitmap.Height -lt 24 -or $bitmap.Width -gt 64 -or $bitmap.Height -gt 64) { + return $null + } + + if ($hotspotX -lt ($bitmap.Width * 0.25) -or $hotspotX -gt ($bitmap.Width * 0.75) -or + $hotspotY -lt ($bitmap.Height * 0.15) -or $hotspotY -gt ($bitmap.Height * 0.55)) { + return $null + } + + $opaquePixels = 0 + $topHalfOpaquePixels = 0 + $left = $bitmap.Width + $top = $bitmap.Height + $right = -1 + $bottom = -1 + + for ($y = 0; $y -lt $bitmap.Height; $y++) { + for ($x = 0; $x -lt $bitmap.Width; $x++) { + if ($bitmap.GetPixel($x, $y).A -le 32) { + continue + } + + $opaquePixels += 1 + if ($y -lt ($bitmap.Height / 2)) { + $topHalfOpaquePixels += 1 + } + if ($x -lt $left) { $left = $x } + if ($x -gt $right) { $right = $x } + if ($y -lt $top) { $top = $y } + if ($y -gt $bottom) { $bottom = $y } + } + } + + if ($opaquePixels -lt 90 -or $right -lt $left -or $bottom -lt $top) { + return $null + } + + $opaqueWidth = $right - $left + 1 + $opaqueHeight = $bottom - $top + 1 + if ($opaqueWidth -lt ($bitmap.Width * 0.35) -or $opaqueWidth -gt ($bitmap.Width * 0.9) -or + $opaqueHeight -lt ($bitmap.Height * 0.45) -or $opaqueHeight -gt $bitmap.Height) { + return $null + } + + if ($top -gt ($bitmap.Height * 0.45) -or $bottom -lt ($bitmap.Height * 0.65)) { + return $null + } + + if ($topHalfOpaquePixels -gt ($opaquePixels * 0.55)) { + return 'closed-hand' + } + + return 'open-hand' +} + function Get-CursorAsset($cursorHandle, $cursorId) { $copiedHandle = [OpenScreenCursorDiagnosticInterop]::CopyIcon($cursorHandle) if ($copiedHandle -eq [IntPtr]::Zero) { @@ -213,6 +269,9 @@ function Get-CursorAsset($cursorHandle, $cursorId) { try { $graphics.Clear([System.Drawing.Color]::Transparent) $graphics.DrawIcon($icon, 0, 0) + $hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } + $hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } + $customCursorType = Get-CustomCursorType -bitmap $bitmap -hotspotX $hotspotX -hotspotY $hotspotY $bitmap.Save($memoryStream, [System.Drawing.Imaging.ImageFormat]::Png) $base64 = [System.Convert]::ToBase64String($memoryStream.ToArray()) @@ -221,8 +280,9 @@ function Get-CursorAsset($cursorHandle, $cursorId) { imageDataUrl = "data:image/png;base64,$base64" width = $bitmap.Width height = $bitmap.Height - hotspotX = if ($hasIconInfo) { $iconInfo.xHotspot } else { 0 } - hotspotY = if ($hasIconInfo) { $iconInfo.yHotspot } else { 0 } + hotspotX = $hotspotX + hotspotY = $hotspotY + cursorType = $customCursorType } } finally { @@ -268,6 +328,8 @@ while ($true) { $asset = Get-CursorAsset -cursorHandle $cursorInfo.hCursor -cursorId $cursorId if ($asset -and $cursorType) { $asset.cursorType = $cursorType + } elseif ($asset -and $asset.cursorType) { + $cursorType = $asset.cursorType } $lastCursorId = $cursorId } @@ -1068,6 +1130,7 @@ const report = { height: asset.height, hotspotX: asset.hotspotX, hotspotY: asset.hotspotY, + cursorType: asset.cursorType ?? null, })), }; const recordingData = toRecordingData(samples, assets); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 5394eee..90390d2 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -39,6 +39,7 @@ import { } from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { nativeBridgeClient, useCursorRecordingData, useCursorTelemetry } from "@/native"; +import type { NativePlatform } from "@/native/contracts"; import { getAspectRatioValue, getNativeAspectRatioValue, @@ -164,6 +165,7 @@ export default function VideoEditor() { const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); + const [nativePlatform, setNativePlatform] = useState(null); const videoPlaybackRef = useRef(null); @@ -172,6 +174,7 @@ export default function VideoEditor() { const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); + const showCursorSettings = nativePlatform === "win32"; // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for // renderers while keeping the persisted value intact for round-tripping. const effectiveCursorHighlight = useMemo( @@ -631,6 +634,27 @@ export default function VideoEditor() { }; }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); + useEffect(() => { + let canceled = false; + nativeBridgeClient.system + .getPlatform() + .then((platform) => { + if (!canceled) { + setNativePlatform(platform); + } + }) + .catch((error) => { + console.warn("Unable to resolve native platform for cursor settings:", error); + if (!canceled) { + setNativePlatform(null); + } + }); + + return () => { + canceled = true; + }; + }, []); + useEffect(() => { if (cursorTelemetryError) { console.warn("Unable to load cursor telemetry:", cursorTelemetryError); @@ -1718,6 +1742,8 @@ export default function VideoEditor() { cursorTelemetry, cursorClickTimestamps, effectiveCursorHighlight, + showCursor, + cursorSize, t, ], ); diff --git a/src/lib/cursor/nativeCursor.ts b/src/lib/cursor/nativeCursor.ts index 04ebccd..6f82e0b 100644 --- a/src/lib/cursor/nativeCursor.ts +++ b/src/lib/cursor/nativeCursor.ts @@ -2,6 +2,8 @@ import { type Container, Point } from "pixi.js"; import appStartingUrl from "@/assets/cursors/Cursor=App-Starting.svg"; import crosshairUrl from "@/assets/cursors/Cursor=Cross.svg"; import arrowUrl from "@/assets/cursors/Cursor=Default.svg"; +import closedHandUrl from "@/assets/cursors/Cursor=Hand-(Grabbing).svg"; +import openHandUrl from "@/assets/cursors/Cursor=Hand-(Open).svg"; import pointerUrl from "@/assets/cursors/Cursor=Hand-(Pointing).svg"; import helpUrl from "@/assets/cursors/Cursor=Help.svg"; import moveUrl from "@/assets/cursors/Cursor=Move.svg"; @@ -78,6 +80,20 @@ const PRETTY_NATIVE_CURSOR_ASSETS: Partial 64 || asset.height < 24 || asset.height > 64) { + return null; + } + + const hotspotXNorm = asset.hotspotX / asset.width; + const hotspotYNorm = asset.hotspotY / asset.height; + const looksLikeChromiumGrabCursor = + hotspotXNorm >= 0.22 && + hotspotXNorm <= 0.55 && + hotspotYNorm >= 0.2 && + hotspotYNorm <= 0.45; + + return looksLikeChromiumGrabCursor ? (PRETTY_NATIVE_CURSOR_ASSETS["open-hand"] ?? null) : null; +} + export function hasNativeCursorRecordingData( recordingData: CursorRecordingData | null | undefined, ): recordingData is CursorRecordingData { @@ -322,7 +354,9 @@ export function resolvePrettyNativeCursorAsset( sample?: CursorRecordingSample, ) { const cursorType = sample?.cursorType ?? asset.cursorType ?? null; - return cursorType ? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null) : null; + return cursorType + ? (PRETTY_NATIVE_CURSOR_ASSETS[cursorType] ?? null) + : resolveUntypedPrettyNativeCursorAsset(asset); } export function resolveNativeCursorRenderAsset( diff --git a/src/native/contracts.ts b/src/native/contracts.ts index a3c9087..ef45336 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -8,6 +8,8 @@ export type NativeCursorType = | "text" | "pointer" | "crosshair" + | "open-hand" + | "closed-hand" | "resize-ew" | "resize-ns" | "resize-nesw"