diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureGeometry.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureGeometry.swift new file mode 100644 index 0000000..676c003 --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureGeometry.swift @@ -0,0 +1,31 @@ +import CoreGraphics + +extension CGRect { + package func inclusivelyContains(_ point: CGPoint) -> Bool { + !isNull && !isInfinite + && point.x >= minX && point.x <= maxX + && point.y >= minY && point.y <= maxY + } + + package func clampedInclusivePoint(_ point: CGPoint) -> CGPoint? { + guard inclusivelyContains(point) else { + return nil + } + return CGPoint( + x: point.x.clamped(to: minX...maxX), + y: point.y.clamped(to: minY...maxY) + ) + } +} + +package func captureOverlayLocalPoint( + from globalPoint: CGPoint, + windowFrame: CGRect, + bounds: CGRect +) -> CGPoint? { + let local = CGPoint( + x: globalPoint.x - windowFrame.minX, + y: globalPoint.y - windowFrame.minY + ) + return bounds.clampedInclusivePoint(local) +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift index a90a3e6..38f5edc 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureHostView.swift @@ -1605,11 +1605,11 @@ final class CaptureHostView: NSView { guard let window else { return nil } - let local = CGPoint( - x: globalPoint.x - window.frame.minX, - y: globalPoint.y - window.frame.minY + return captureOverlayLocalPoint( + from: globalPoint, + windowFrame: window.frame, + bounds: bounds ) - return bounds.contains(local) ? local : nil } private func currentLocalMousePoint() -> CGPoint? { @@ -1617,7 +1617,7 @@ final class CaptureHostView: NSView { return nil } let localPoint = window.mouseLocationOutsideOfEventStream - return bounds.contains(localPoint) ? localPoint : nil + return bounds.clampedInclusivePoint(localPoint) } private func currentCursorPresentation() -> CursorPresentation { @@ -1765,7 +1765,7 @@ final class CaptureHostView: NSView { } let localPoint = window.mouseLocationOutsideOfEventStream let globalPoint = window.convertPoint(toScreen: localPoint) - return NSScreen.screens.contains(where: { $0.frame.contains(globalPoint) }) + return NSScreen.screens.contains(where: { $0.frame.inclusivelyContains(globalPoint) }) ? globalPoint : nil } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift index 24b67db..4a5283f 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureOverlayController.swift @@ -77,7 +77,7 @@ final class CaptureOverlayController { settings: settings ) windows.append(window) - if targetWindow == nil, screen.frame.contains(focusPoint) { + if targetWindow == nil, screen.frame.inclusivelyContains(focusPoint) { targetWindow = window } } @@ -213,7 +213,9 @@ final class CaptureOverlayController { } func focusWindow(at point: CGPoint) { - guard let targetWindow = windows.first(where: { $0.frame.contains(point) }) ?? windows.first + guard + let targetWindow = windows.first(where: { $0.frame.inclusivelyContains(point) }) + ?? windows.first else { return } @@ -355,14 +357,14 @@ final class CaptureOverlayController { } func hoverWindow(at point: CGPoint) -> WindowSnapshot? { - guard NSScreen.screens.contains(where: { $0.frame.contains(point) }) else { + guard NSScreen.screens.contains(where: { $0.frame.inclusivelyContains(point) }) else { return nil } return windowSnapshotFeed.window(at: point) } func hoverWindowPreview(at point: CGPoint) -> WindowSnapshot? { - guard NSScreen.screens.contains(where: { $0.frame.contains(point) }) else { + guard NSScreen.screens.contains(where: { $0.frame.inclusivelyContains(point) }) else { return nil } return windowSnapshotFeed.window(at: point) @@ -472,7 +474,7 @@ final class CaptureOverlayController { near point: CGPoint ) -> CaptureSessionController.FrozenCaptureJobSource? { guard - let referenceWindow = windows.first(where: { $0.frame.contains(point) }) + let referenceWindow = windows.first(where: { $0.frame.inclusivelyContains(point) }) ?? windows.first else { return nil @@ -492,13 +494,13 @@ final class CaptureOverlayController { fileprivate func liveColorSampleSource(near point: CGPoint) -> LiveColorSampleSource? { guard - let referenceWindow = windows.first(where: { $0.frame.contains(point) }) + let referenceWindow = windows.first(where: { $0.frame.inclusivelyContains(point) }) ?? windows.first else { return nil } let screen = - NSScreen.screens.first(where: { $0.frame.contains(point) }) + NSScreen.screens.first(where: { $0.frame.inclusivelyContains(point) }) ?? referenceWindow.screen guard let displayID = screen.flatMap(Self.displayID) else { return nil @@ -827,7 +829,7 @@ final class CaptureOverlayController { let focusPoint = CGPoint(x: selection.midX, y: selection.midY) guard - let primaryWindow = windows.first(where: { $0.frame.contains(focusPoint) }) + let primaryWindow = windows.first(where: { $0.frame.inclusivelyContains(focusPoint) }) ?? windows.first else { return diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Runtime.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Runtime.swift index a4c12e7..f91f7a5 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Runtime.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+Runtime.swift @@ -5,7 +5,7 @@ import RsnapHostBridge extension CaptureSessionController { func screen(containing point: CGPoint) -> NSScreen? { - NSScreen.screens.first(where: { $0.frame.contains(point) }) + NSScreen.screens.first(where: { $0.frame.inclusivelyContains(point) }) } func activeMonitor(at point: CGPoint) -> MonitorSnapshot? { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift index f278c83..743a716 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureSessionController+ScrollCapture.swift @@ -1367,9 +1367,3 @@ private func scrollCaptureFlippedDesktopPoint(_ point: CGPoint) -> CGPoint { y: desktop.minY + desktop.maxY - point.y ) } - -extension CGRect { - fileprivate func inclusivelyContains(_ point: CGPoint) -> Bool { - point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY - } -} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift index 36763af..548d1e4 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift @@ -760,7 +760,10 @@ final class FrozenFrameAuthority: @unchecked Sendable { defer { stateLock.unlock() } - guard let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key + guard + let displayID = displayTargets.first(where: { + $0.value.frame.inclusivelyContains(point) + })?.key else { return nil } @@ -783,7 +786,10 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard selfCaptureFilterRequired else { return false } - guard let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key + guard + let displayID = displayTargets.first(where: { + $0.value.frame.inclusivelyContains(point) + })?.key else { return false } @@ -800,7 +806,10 @@ final class FrozenFrameAuthority: @unchecked Sendable { defer { stateLock.unlock() } - guard let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key, + guard + let displayID = displayTargets.first(where: { + $0.value.frame.inclusivelyContains(point) + })?.key, let record = latestFrames[displayID], let eligibleRecord = snapshotEligibleRecordLocked(record) else { @@ -817,7 +826,10 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard selfCaptureFilterRequired else { return false } - guard let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key, + guard + let displayID = displayTargets.first(where: { + $0.value.frame.inclusivelyContains(point) + })?.key, let stream = streams[displayID] else { return false @@ -831,7 +843,8 @@ final class FrozenFrameAuthority: @unchecked Sendable { func liveRgbSample(containing point: CGPoint) -> LiveRgbSample? { stateLock.lock() - let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key + let displayID = displayTargets.first(where: { $0.value.frame.inclusivelyContains(point) })? + .key let record = displayID.flatMap { latestFrames[$0] }.flatMap(snapshotEligibleRecordLocked) stateLock.unlock() guard let record else { @@ -858,7 +871,8 @@ final class FrozenFrameAuthority: @unchecked Sendable { func loupePatch(containing point: CGPoint, sidePixels: Int) -> CGImage? { stateLock.lock() - let displayID = displayTargets.first(where: { $0.value.frame.contains(point) })?.key + let displayID = displayTargets.first(where: { $0.value.frame.inclusivelyContains(point) })? + .key let record = displayID.flatMap { latestFrames[$0] }.flatMap(snapshotEligibleRecordLocked) stateLock.unlock() guard let record else { @@ -880,7 +894,8 @@ final class FrozenFrameAuthority: @unchecked Sendable { let deadline = Date(timeIntervalSinceNow: max(0, maxWait)) stateLock.lock() let displayID = - token?.displayID ?? displayTargets.first(where: { $0.value.frame.contains(point) })?.key + token?.displayID + ?? displayTargets.first(where: { $0.value.frame.inclusivelyContains(point) })?.key guard let displayID else { stateLock.unlock() return nil diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift index de12177..fffd8a6 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift @@ -90,7 +90,7 @@ final class LiveFrameStreamBroker: @unchecked Sendable { Self.monitorSnapshot(for: $0, mainDisplayHeight: mainDisplayHeight) } let targetMonitor = prewarmPoint.flatMap { point in - nextMonitors.first(where: { $0.appKitFrame.contains(point) }) + nextMonitors.first(where: { $0.appKitFrame.inclusivelyContains(point) }) } let monitorsUnchanged = nextMonitors == monitors monitors = nextMonitors @@ -308,7 +308,7 @@ final class LiveFrameStreamBroker: @unchecked Sendable { stateLock.lock() let monitors = self.monitors stateLock.unlock() - return monitors.first(where: { $0.appKitFrame.contains(point) }) + return monitors.first(where: { $0.appKitFrame.inclusivelyContains(point) }) } private static func makeSampler(exceptionWindowIDs: Set) -> RsnapLiveSampler? { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index 4922ac5..df8ce62 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -79,7 +79,7 @@ final class WindowSnapshotFeed { stateLock.lock() let snapshots = latestSnapshots stateLock.unlock() - return snapshots.first(where: { $0.frame.contains(point) }) + return snapshots.first(where: { $0.frame.inclusivelyContains(point) }) } static func snapshots(desktopFrame: CGRect) -> [WindowSnapshot] { @@ -137,7 +137,7 @@ final class WindowSnapshotFeed { } static func window(at point: CGPoint, in snapshots: [WindowSnapshot]) -> WindowSnapshot? { - snapshots.first(where: { $0.frame.contains(point) }) + snapshots.first(where: { $0.frame.inclusivelyContains(point) }) } private func refresh() { diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index ca021a2..1b51ab2 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -9,6 +9,7 @@ enum RsnapNativeHostKitProbe { assertScrimExclusionPreservesExistingPixels() assertRoundedExclusionMaskKeepsCornersFilled() assertCaptureFrameEffectExpandsExportCanvas() + assertCaptureOverlayLocalPointKeepsScreenEdgesVisible() assertScrollCaptureViewportPointAcceptsFlippedGlobalMouseCoordinates() assertScrollCaptureObservedInputAcceptsSourceWindowGutter() assertSoftwareUpdateModeResolution() @@ -97,6 +98,41 @@ enum RsnapNativeHostKitProbe { } } + private static func assertCaptureOverlayLocalPointKeepsScreenEdgesVisible() { + let windowFrame = CGRect(x: 0, y: 0, width: 1_440, height: 900) + let bounds = CGRect(x: 0, y: 0, width: 1_440, height: 900) + + guard + captureOverlayLocalPoint( + from: CGPoint(x: windowFrame.maxX, y: windowFrame.maxY), + windowFrame: windowFrame, + bounds: bounds + ) == CGPoint(x: bounds.maxX, y: bounds.maxY) + else { + fatalError("capture overlay should keep HUD placement alive at screen max edges") + } + + guard + captureOverlayLocalPoint( + from: CGPoint(x: windowFrame.minX, y: windowFrame.minY), + windowFrame: windowFrame, + bounds: bounds + ) == CGPoint(x: bounds.minX, y: bounds.minY) + else { + fatalError("capture overlay should keep HUD placement alive at screen min edges") + } + + guard + captureOverlayLocalPoint( + from: CGPoint(x: windowFrame.maxX + 1, y: windowFrame.midY), + windowFrame: windowFrame, + bounds: bounds + ) == nil + else { + fatalError("capture overlay should still reject points outside the screen edge") + } + } + private static func assertRectEqual(_ actual: CGRect, _ expected: CGRect, _ message: String) { guard nearlyEqual(actual.origin.x, expected.origin.x), nearlyEqual(actual.origin.y, expected.origin.y),