From 4f11c3d3b4e585ef68ab057921e6a6da21422fd0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 5 May 2026 20:12:18 +0800 Subject: [PATCH] {"schema":"maestro/commit/1","summary":"Fix frozen entry and HUD regressions","authority":"manual"} --- .../FrozenFrameAuthority.swift | 3 +- .../LiveOverlayRenderer.swift | 624 ++++++++++++++---- .../RsnapNativeHostKit/NativeHostApp.swift | 291 +++++++- .../NativeHostDisplayRefresh.swift | 2 +- .../NativeHostSettings.swift | 6 +- .../NativeHostSettingsView.swift | 4 +- scripts/smoke/lib/live-hud-mouse-path.swift | 19 +- scripts/smoke/lib/live-hud.sh | 7 + scripts/smoke/lib/mask-probe-capture.swift | 27 +- .../lib/native-visual-contract-summary.py | 73 +- scripts/smoke/lib/replay-scroll-capture.sh | 43 ++ scripts/smoke/native-hud-follow-macos.sh | 7 + scripts/smoke/native-visual-contract-macos.sh | 166 +++-- 13 files changed, 1001 insertions(+), 271 deletions(-) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift index 24889034..69d7b289 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift @@ -38,7 +38,8 @@ struct FrozenFrameSnapshot: @unchecked Sendable { /// screenshots just because a pixel buffer happens to exist. final class FrozenFrameAuthority: @unchecked Sendable { private static let maximumSnapshotAgeMilliseconds = 150.0 - private static let maximumLiveRgbAgeMilliseconds = 100.0 + private static let maximumLiveRgbAgeMilliseconds = + LiveRgbSample.maximumDisplayAge * 1_000 private static let selfCaptureFilterRetryInterval: TimeInterval = 0.035 private static let selfCaptureFilterRetryWindow: TimeInterval = 2.5 diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index d3088c68..a683bc00 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -209,6 +209,10 @@ final class ChromeSampleFeed: @unchecked Sendable { private static let backgroundProbeMinimumInterval: TimeInterval = 0.25 private static let backgroundProbeIdleDelay: TimeInterval = 0.08 private static let maximumBackgroundSamplesInFlight = 1 + private static let sampleTimerWakeupLeadRatio = 0.94 + private static let loupePatchSampleMinimumInterval = + NativeHostDisplayRefresh.frameInterval( + forTargetFramesPerSecond: NativeHostDisplayRefresh.fallbackFramesPerSecond) private static let sampleUpdatedNotificationIdleDelay = NativeHostDisplayRefresh.frameInterval( forTargetFramesPerSecond: NativeHostDisplayRefresh.fallbackFramesPerSecond) @@ -219,6 +223,7 @@ final class ChromeSampleFeed: @unchecked Sendable { private var backgroundCorrectionMode = false private var whiteStreamRunHasProbed = false private var lastBackgroundProbeUptime: TimeInterval = 0 + private var lastLoupePatchRefreshUptime: TimeInterval = 0 private var desiredPoint: CGPoint? private var desiredSidePixels: Int = 1 private var desiredIncludesLoupePatch = false @@ -274,7 +279,7 @@ final class ChromeSampleFeed: @unchecked Sendable { Int( (NativeHostDisplayRefresh.frameInterval( forTargetFramesPerSecond: targetFramesPerSecond) - * NativeHostDisplayRefresh.timerWakeupLeadRatio + * Self.sampleTimerWakeupLeadRatio * 1_000_000_000.0).rounded()) ) timer.schedule( @@ -310,6 +315,7 @@ final class ChromeSampleFeed: @unchecked Sendable { backgroundCorrectionMode = false whiteStreamRunHasProbed = false lastBackgroundProbeUptime = 0 + lastLoupePatchRefreshUptime = 0 lastRefreshUptime = nil lastPointChangeUptime = ProcessInfo.processInfo.systemUptime activeCaptureID = 0 @@ -419,6 +425,7 @@ final class ChromeSampleFeed: @unchecked Sendable { let previousSample = latestSample let previousPoint = latestSamplePoint let lastRefreshUptime = self.lastRefreshUptime + let lastLoupePatchRefreshUptime = self.lastLoupePatchRefreshUptime let pointIdleDuration = now - lastPointChangeUptime refreshCount &+= 1 let currentRefreshCount = refreshCount @@ -434,18 +441,25 @@ final class ChromeSampleFeed: @unchecked Sendable { stateLock.lock() latestSample = nil latestSamplePoint = nil + self.lastLoupePatchRefreshUptime = 0 stateLock.unlock() return } let frameRgbSample = frameRgbSampler(point) - let framePatchSample = + let canReuseRecentPatch = includeLoupePatch - ? framePatchSampler(point, sidePixels) - : nil + && previousSample?.loupePatch != nil + && now - lastLoupePatchRefreshUptime < Self.loupePatchSampleMinimumInterval + let shouldRefreshLoupePatch = + includeLoupePatch && !canReuseRecentPatch let streamSample = - includeLoupePatch && framePatchSample == nil + shouldRefreshLoupePatch ? broker.sample(at: point, sidePixels: sidePixels) : nil + let framePatchSample = + shouldRefreshLoupePatch && streamSample?.loupePatch == nil + ? framePatchSampler(point, sidePixels) + : nil let streamRgbSample = frameRgbSample == nil ? (streamSample?.rgb ?? broker.rgbSample(at: point)) @@ -482,6 +496,10 @@ final class ChromeSampleFeed: @unchecked Sendable { let patchSample = Self.sampleWithUpdatedPatch(rgb: nil, loupePatch: framePatchSample) ?? streamSample + ?? Self.recentPatchSample( + previousSample: previousSample, + canReuseRecentPatch: canReuseRecentPatch + ) ?? Self.reusablePatchSample( previousSample: previousSample, previousPoint: previousPoint, @@ -495,6 +513,9 @@ final class ChromeSampleFeed: @unchecked Sendable { stateLock.lock() latestSample = sample latestSamplePoint = sample == nil ? nil : point + if framePatchSample != nil || streamSample?.loupePatch != nil { + self.lastLoupePatchRefreshUptime = now + } let firstRgbTelemetry = makeFirstRgbTelemetryLocked( rgbSample: rgbSample?.rgb, source: rgbSource, @@ -604,6 +625,16 @@ final class ChromeSampleFeed: @unchecked Sendable { return previousSample } + private static func recentPatchSample( + previousSample: LiveChromeSample?, + canReuseRecentPatch: Bool + ) -> LiveChromeSample? { + guard canReuseRecentPatch, let loupePatch = previousSample?.loupePatch else { + return nil + } + return LiveChromeSample(rgb: nil, loupePatch: loupePatch) + } + private static func pointsEquivalent(_ lhs: CGPoint, _ rhs: CGPoint) -> Bool { abs(lhs.x - rhs.x) <= 0.5 && abs(lhs.y - rhs.y) <= 0.5 } @@ -1347,13 +1378,19 @@ final class LiveOverlayRenderer { category: "LiveChromeTelemetry" ) private static let activeInputWindow: TimeInterval = 0.25 - private static let hudColorPendingAnimationKey = "rsnap.hud.color.pending" private static let hudColorResolveAnimationKey = "rsnap.hud.color.resolve" private static let hudColorResolveBackgroundAnimationKey = "rsnap.hud.color.resolve.background" private static let hudColorRollAnimationKey = "rsnap.hud.color.roll" - private static let hudColorRollDuration: TimeInterval = 0.52 - private static let hudColorRollDigitStagger: TimeInterval = 0.032 + private static let hudColorPendingRollAnimationKey = "rsnap.hud.color.pending.roll" + private static let hudColorRollDuration: TimeInterval = 0.40 + private static let hudColorRollDigitStagger: TimeInterval = 0.024 private static let hexWheel = Array("0123456789ABCDEF") + private static let pendingHexRollBaseSeed: UInt64 = 0x5EED_71A5_C01D + private struct HudHexPendingRollColumnState { + let digits: [Character] + let scrollsUp: Bool + let contentLayer: CALayer + } private enum LayerZ { static let frozenDisplay: CGFloat = 0 static let scrim: CGFloat = 10 @@ -1370,7 +1407,10 @@ final class LiveOverlayRenderer { private var lastHudColorPending: Bool? private var hudColorRevealArmed = true private var activeHudHexRollTarget: String? + private var activeHudHexRollSwatchColor: CGColor? private var hudHexRollAnimationEndUptime: TimeInterval? + private var hudHexPendingRollActive = false + private var hudHexPendingRollColumns: [HudHexPendingRollColumnState] = [] init(hostView: NSView) { self.hostView = hostView @@ -1926,18 +1966,15 @@ final class LiveOverlayRenderer { textColor: NSColor ) { if isPending { - clearHudHexRollAnimation() - hudHexLayer.isHidden = false hudSwatchLayer.removeAnimation(forKey: Self.hudColorResolveAnimationKey) hudSwatchLayer.removeAnimation(forKey: Self.hudColorResolveBackgroundAnimationKey) + hudSwatchLayer.opacity = 1 hudHexLayer.removeAnimation(forKey: Self.hudColorResolveAnimationKey) - if hudColorRevealArmed { - ensureHudColorPendingPulse(on: hudSwatchLayer, from: 0.44, to: 0.78) - ensureHudColorPendingPulse(on: hudHexLayer, from: 0.48, to: 0.86) - } else { - hudSwatchLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) - hudHexLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) - } + beginOrUpdateHudHexPendingRollAnimation( + frame: hexFrame, + font: font, + textColor: textColor + ) lastHudColorPending = true return } @@ -1949,13 +1986,16 @@ final class LiveOverlayRenderer { let priorSwatchOpacity = wasPending ? hudSwatchLayer.presentation()?.opacity : nil let priorHexOpacity = wasPending ? hudHexLayer.presentation()?.opacity : nil - hudSwatchLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) - hudHexLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) lastHudColorPending = false hudColorRevealArmed = false guard shouldAnimateReveal else { - updateHudHexRollVisibility(target: resolvedHexText, frame: hexFrame) + updateHudHexRollVisibility( + target: resolvedHexText, + frame: hexFrame, + font: font, + textColor: textColor + ) return } @@ -1968,7 +2008,8 @@ final class LiveOverlayRenderer { frame: hexFrame, font: font, textColor: textColor, - initialOpacity: priorHexOpacity.map(CGFloat.init) ?? 0.62 + initialOpacity: priorHexOpacity.map(CGFloat.init) ?? 0.62, + targetSwatchColor: resolvedSwatchColor ) let colorAnimation = CABasicAnimation(keyPath: "backgroundColor") colorAnimation.fromValue = priorSwatchColor ?? pendingSwatchColor.cgColor @@ -1981,24 +2022,6 @@ final class LiveOverlayRenderer { ) } - private func ensureHudColorPendingPulse( - on layer: CALayer, - from fromOpacity: Float, - to toOpacity: Float - ) { - guard layer.animation(forKey: Self.hudColorPendingAnimationKey) == nil else { - return - } - let animation = CABasicAnimation(keyPath: "opacity") - animation.fromValue = fromOpacity - animation.toValue = toOpacity - animation.duration = 0.42 - animation.autoreverses = true - animation.repeatCount = .infinity - animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - layer.add(animation, forKey: Self.hudColorPendingAnimationKey) - } - private func addHudColorResolveAnimation(to layer: CALayer, fromOpacity: CGFloat) { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = fromOpacity @@ -2008,19 +2031,221 @@ final class LiveOverlayRenderer { layer.add(animation, forKey: Self.hudColorResolveAnimationKey) } - private func updateHudHexRollVisibility(target: String, frame: CGRect) { - guard let animationEnd = hudHexRollAnimationEndUptime, - ProcessInfo.processInfo.systemUptime < animationEnd, - activeHudHexRollTarget == target - else { + private func updateHudHexRollVisibility( + target: String, + frame: CGRect, + font: NSFont, + textColor: NSColor + ) { + guard let activeTarget = activeHudHexRollTarget else { + clearHudHexRollAnimation() + hudHexLayer.isHidden = false + return + } + let now = ProcessInfo.processInfo.systemUptime + if activeTarget != target { + if let animationEnd = hudHexRollAnimationEndUptime { + if now < animationEnd { + if let activeHudHexRollSwatchColor { + hudSwatchLayer.backgroundColor = activeHudHexRollSwatchColor + } + hudHexLayer.isHidden = true + hudHexRollLayer.isHidden = false + hudHexRollLayer.frame = frame + return + } + finishHudHexRollAnimation() + } clearHudHexRollAnimation() hudHexLayer.isHidden = false return } + if let animationEnd = hudHexRollAnimationEndUptime, + now >= animationEnd + { + finishHudHexRollAnimation() + } + + hudHexLayer.isHidden = true + hudHexRollLayer.isHidden = false + hudHexRollLayer.frame = frame + } + + private func finishHudHexRollAnimation() { + hudHexRollAnimationEndUptime = nil + activeHudHexRollSwatchColor = nil + hudHexPendingRollActive = false + hudHexPendingRollColumns.removeAll(keepingCapacity: true) + removeHudHexRollLayerAnimations() + } + private func beginOrUpdateHudHexPendingRollAnimation( + frame: CGRect, + font: NSFont, + textColor: NSColor + ) { + hudHexLayer.isHidden = true + hudHexRollLayer.isHidden = false + hudHexRollLayer.frame = frame + guard !hudHexPendingRollActive else { + return + } + + clearHudHexRollAnimation() + hudHexPendingRollActive = true + hudHexPendingRollColumns.removeAll(keepingCapacity: true) hudHexLayer.isHidden = true hudHexRollLayer.isHidden = false hudHexRollLayer.frame = frame + + let lineHeight = ceil(LiveOverlayTypography.lineHeight) + let characterFrames = hudHexCharacterFrames( + for: "#FFFFFF", + font: font, + lineHeight: lineHeight + ) + let hashLayer = makeHudHexRollTextLayer( + text: "#", + font: font, + color: textColor.withAlphaComponent(0.72), + frame: characterFrames.first ?? CGRect(x: 0, y: 0, width: 0, height: lineHeight) + ) + hudHexRollLayer.addSublayer(hashLayer) + + for index in 0..<6 { + let characterFrame = + index + 1 < characterFrames.count + ? characterFrames[index + 1] + : CGRect(x: 0, y: 0, width: 0, height: lineHeight) + let columnLayer = CALayer() + columnLayer.masksToBounds = true + columnLayer.frame = characterFrame + hudHexRollLayer.addSublayer(columnLayer) + let columnState = addHudHexPendingRollColumn( + to: columnLayer, + index: index, + font: font, + textColor: textColor, + lineHeight: lineHeight, + digitWidth: characterFrame.width + ) + hudHexPendingRollColumns.append(columnState) + } + } + private static func pendingHexRollSeed(index: Int) -> UInt64 { + let uptimeBucket = UInt64((ProcessInfo.processInfo.systemUptime * 1_000).rounded(.down)) + let mixedIndex = UInt64(index + 1) &* 0x9E37_79B9_7F4A_7C15 + return pendingHexRollBaseSeed ^ uptimeBucket ^ mixedIndex + } + + private static func pendingHexRollSequence(index: Int) -> [Character] { + var seed = pendingHexRollSeed(index: index) + seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + let visibleRows = 47 + Int((seed >> 57) & 0x1F) + index * 3 + var digits: [Character] = [] + digits.reserveCapacity(visibleRows + 1) + var previous: Character? + for offset in 0..> 58) & 0xF) + if let previousDigit = previous, + Self.hexWheel[wheelIndex] == previousDigit + { + wheelIndex = (wheelIndex + offset + index + 1) % Self.hexWheel.count + } + let digit = Self.hexWheel[wheelIndex] + digits.append(digit) + previous = digit + } + if let first = digits.first { + digits.append(first) + } + return digits + } + + private static func pendingHexRollColumnDuration(index: Int) -> TimeInterval { + let seed = + pendingHexRollSeed(index: index) + &* 2_862_933_555_777_941_757 + &+ 3_037_000_493 + return 1.58 + Double((seed >> 56) & 0x1F) * 0.031 + } + + private static func pendingHexRollColumnPhase(index: Int, duration: TimeInterval) + -> TimeInterval + { + let seed = + pendingHexRollSeed(index: index) + &* 11_400_714_819_323_198_485 + &+ 12_829_314 + let ratio = Double((seed >> 40) & 0xFFFF) / 65_535.0 + return duration * ratio + } + + private static func pendingHexRollColumnScrollsUp(index: Int) -> Bool { + let uptimeBucket = UInt64((ProcessInfo.processInfo.systemUptime * 1_000).rounded(.down)) + let startsUp = ((pendingHexRollBaseSeed ^ uptimeBucket) & 1) == 0 + if index <= 1 { + return index == 0 ? startsUp : !startsUp + } + let seed = + pendingHexRollSeed(index: index) + &* 3_202_034_522_624_059_733 + &+ 1_029 + return ((seed >> 63) & 1) == 0 + } + + private static func resolveHexRollColumnScrollsUp( + index: Int, + startDigit: Character, + targetDigit: Character + ) -> Bool { + let startValue = UInt64(startDigit.unicodeScalars.first?.value ?? 0) + let targetValue = UInt64(targetDigit.unicodeScalars.first?.value ?? 0) + let seed = + pendingHexRollSeed(index: index) + ^ (startValue &* 1_099_511_628_211) + ^ (targetValue &* 2_862_933_555_777_941_757) + let startsUp = ((seed >> 63) & 1) == 0 + if index <= 1 { + return index == 0 ? startsUp : !startsUp + } + return ((seed >> 59) & 1) == 0 + } + + private static func resolveHexRollExtraLoops(index: Int, targetDigit: Character) -> Int { + let targetValue = UInt64(targetDigit.unicodeScalars.first?.value ?? 0) + let seed = + pendingHexRollSeed(index: index) + ^ (targetValue &* 11_400_714_819_323_198_485) + return 1 + Int((seed >> 60) & 1) + } + + private static func resolveHexRollSequence( + from startDigit: Character, + to targetDigit: Character, + index: Int, + scrollsUp: Bool + ) -> [Character] { + let wheelCount = max(hexWheel.count, 1) + let startIndex = hexWheel.firstIndex(of: startDigit) ?? 0 + let targetIndex = hexWheel.firstIndex(of: targetDigit) ?? startIndex + let directedDistance = + scrollsUp + ? (targetIndex - startIndex + wheelCount) % wheelCount + : (startIndex - targetIndex + wheelCount) % wheelCount + let extraSteps = + resolveHexRollExtraLoops(index: index, targetDigit: targetDigit) + * wheelCount + let totalSteps = + directedDistance + extraSteps + return (0...totalSteps).map { offset in + let wheelIndex = + scrollsUp + ? (startIndex + offset) % wheelCount + : (startIndex - offset + (totalSteps + wheelCount) * wheelCount) % wheelCount + return hexWheel[wheelIndex] + } } private func beginHudHexRollAnimation( @@ -2028,22 +2253,22 @@ final class LiveOverlayRenderer { frame: CGRect, font: NSFont, textColor: NSColor, - initialOpacity: CGFloat + initialOpacity: CGFloat, + targetSwatchColor: NSColor? = nil ) { + let lineHeight = ceil(LiveOverlayTypography.lineHeight) + let startDigits = currentPendingHudHexDigits(lineHeight: lineHeight) + let pendingDirections = hudHexPendingRollColumns.map(\.scrollsUp) clearHudHexRollAnimation() activeHudHexRollTarget = target + activeHudHexRollSwatchColor = targetSwatchColor?.cgColor let now = ProcessInfo.processInfo.systemUptime let targetDigits = Array(target.dropFirst()) - let digitCount = max(targetDigits.count, 0) - hudHexRollAnimationEndUptime = - now + Self.hudColorRollDuration - + (Double(max(digitCount - 1, 0)) * Self.hudColorRollDigitStagger) - + 0.08 + var rollEndOffset: TimeInterval = 0 hudHexLayer.isHidden = true hudHexRollLayer.isHidden = false hudHexRollLayer.frame = frame - let lineHeight = ceil(LiveOverlayTypography.lineHeight) let characterFrames = hudHexCharacterFrames( for: target, font: font, @@ -2066,17 +2291,35 @@ final class LiveOverlayRenderer { columnLayer.masksToBounds = true columnLayer.frame = characterFrame hudHexRollLayer.addSublayer(columnLayer) - addHudHexRollDigit( + let startDigit = + index < startDigits.count + ? startDigits[index] + : nil + let resolvedStartDigit = startDigit ?? Self.hexWheel.first ?? targetDigit + let scrollsUp = + index < pendingDirections.count + ? pendingDirections[index] + : Self.resolveHexRollColumnScrollsUp( + index: index, + startDigit: resolvedStartDigit, + targetDigit: targetDigit + ) + let columnEndOffset = addHudHexRollDigit( to: columnLayer, - targetDigit: String(targetDigit), + startDigit: resolvedStartDigit, + targetDigit: targetDigit, index: index, font: font, textColor: textColor, initialOpacity: initialOpacity, lineHeight: lineHeight, - digitWidth: characterFrame.width + digitWidth: characterFrame.width, + scrollsUp: scrollsUp ) + hudHexPendingRollColumns.append(columnEndOffset.state) + rollEndOffset = max(rollEndOffset, columnEndOffset.endOffset) } + hudHexRollAnimationEndUptime = now + rollEndOffset + 0.03 } private func hudHexCharacterFrames( @@ -2097,97 +2340,186 @@ final class LiveOverlayRenderer { } } - private func addHudHexRollDigit( + private func addHudHexPendingRollColumn( to columnLayer: CALayer, - targetDigit: String, index: Int, font: NSFont, textColor: NSColor, - initialOpacity: CGFloat, lineHeight: CGFloat, digitWidth: CGFloat - ) { - let baseFrame = CGRect(x: 0, y: 0, width: digitWidth, height: lineHeight) - let startLayer = makeHudHexRollTextLayer( - text: "0", - font: font, - color: textColor.withAlphaComponent(0.58), - frame: baseFrame + ) -> HudHexPendingRollColumnState { + var digits = Self.pendingHexRollSequence(index: index) + let scrollsUp = Self.pendingHexRollColumnScrollsUp(index: index) + if !scrollsUp { + digits.reverse() + } + let contentText = digits.map(String.init).joined(separator: "\n") + let contentLayer = CALayer() + contentLayer.frame = CGRect( + x: 0, + y: 0, + width: digitWidth, + height: lineHeight * CGFloat(digits.count) ) - startLayer.opacity = 0 - columnLayer.addSublayer(startLayer) + columnLayer.addSublayer(contentLayer) - let firstTumblerLayer = makeHudHexRollTextLayer( - text: tumblerDigit(for: targetDigit, index: index, offset: 5), + let digitLayer = makeHudHexRollMultilineTextLayer( + text: contentText, font: font, - color: textColor.withAlphaComponent(0.62), - frame: baseFrame + color: textColor.withAlphaComponent(0.72), + lineHeight: lineHeight, + frame: contentLayer.bounds + ) + contentLayer.addSublayer(digitLayer) + + let animation = CABasicAnimation(keyPath: "transform.translation.y") + let travel = lineHeight * CGFloat(max(digits.count - 1, 1)) + animation.fromValue = scrollsUp ? 0 : -travel + animation.toValue = scrollsUp ? -travel : 0 + let duration = Self.pendingHexRollColumnDuration(index: index) + animation.duration = duration + animation.beginTime = + CACurrentMediaTime() + - Self.pendingHexRollColumnPhase(index: index, duration: duration) + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.isRemovedOnCompletion = false + contentLayer.add(animation, forKey: Self.hudColorPendingRollAnimationKey) + return HudHexPendingRollColumnState( + digits: digits, + scrollsUp: scrollsUp, + contentLayer: contentLayer ) - firstTumblerLayer.opacity = 0 - columnLayer.addSublayer(firstTumblerLayer) + } - let secondTumblerLayer = makeHudHexRollTextLayer( - text: tumblerDigit(for: targetDigit, index: index, offset: 11), - font: font, - color: textColor.withAlphaComponent(0.72), - frame: baseFrame + private func currentPendingHudHexDigits(lineHeight: CGFloat) -> [Character?] { + hudHexPendingRollColumns.map { column in + guard !column.digits.isEmpty else { + return nil + } + let presentationLayer = column.contentLayer.presentation() ?? column.contentLayer + let translationY = presentationLayer.transform.m42 + let rawIndex = Int((-translationY / lineHeight).rounded()) + let visibleIndex = min(max(rawIndex, 0), column.digits.count - 1) + return column.digits[visibleIndex] + } + } + + private func addHudHexRollDigit( + to columnLayer: CALayer, + startDigit: Character, + targetDigit: Character, + index: Int, + font: NSFont, + textColor: NSColor, + initialOpacity: CGFloat, + lineHeight: CGFloat, + digitWidth: CGFloat, + scrollsUp: Bool + ) -> (state: HudHexPendingRollColumnState, endOffset: TimeInterval) { + let rollDigits = Self.resolveHexRollSequence( + from: startDigit, + to: targetDigit, + index: index, + scrollsUp: scrollsUp + ) + let terminalPaddingRows = 2 + let contentDigits: [Character] + let startRowIndex: Int + let targetRowIndex: Int + if scrollsUp { + contentDigits = + rollDigits + + Array(repeating: targetDigit, count: terminalPaddingRows) + startRowIndex = 0 + targetRowIndex = max(rollDigits.count - 1, 0) + } else { + contentDigits = + Array(repeating: targetDigit, count: terminalPaddingRows) + + Array(rollDigits.reversed()) + startRowIndex = max(contentDigits.count - 1, 0) + targetRowIndex = terminalPaddingRows + } + let contentLayer = CALayer() + contentLayer.opacity = Float(max(initialOpacity, 0.72)) + contentLayer.frame = CGRect( + x: 0, + y: 0, + width: digitWidth, + height: lineHeight * CGFloat(contentDigits.count) ) - secondTumblerLayer.opacity = 0 - columnLayer.addSublayer(secondTumblerLayer) + columnLayer.addSublayer(contentLayer) - let targetLayer = makeHudHexRollTextLayer( - text: targetDigit, + addHudHexRollDigitStack( + to: contentLayer, + digits: contentDigits, font: font, color: textColor, - frame: baseFrame + lineHeight: lineHeight, + digitWidth: digitWidth ) - targetLayer.opacity = 0 - columnLayer.addSublayer(targetLayer) + + let fromY = -lineHeight * CGFloat(startRowIndex) + let toY = -lineHeight * CGFloat(targetRowIndex) + contentLayer.transform = CATransform3DMakeTranslation(0, toY, 0) let stagger = Double(index) * Self.hudColorRollDigitStagger - addHudRollAnimation( - to: startLayer, - fromY: 0, - toY: -lineHeight * 0.92, - opacityValues: [max(initialOpacity, 0.52), 0], - keyTimes: [0, 1], - beginOffset: stagger, - duration: 0.18 - ) - addHudRollAnimation( - to: firstTumblerLayer, - fromY: lineHeight * 1.05, - toY: -lineHeight * 0.78, - opacityValues: [0, 0.58, 0], - keyTimes: [0, 0.48, 1], - beginOffset: stagger + 0.045, - duration: 0.22 - ) - addHudRollAnimation( - to: secondTumblerLayer, - fromY: lineHeight * 0.98, - toY: -lineHeight * 0.55, - opacityValues: [0, 0.72, 0], - keyTimes: [0, 0.55, 1], - beginOffset: stagger + 0.15, - duration: 0.24 - ) - addHudRollAnimation( - to: targetLayer, - fromY: lineHeight * 0.9, - toY: 0, - opacityValues: [0, 1, 1], - keyTimes: [0, 0.7, 1], - beginOffset: stagger + 0.28, - duration: 0.24, - timing: .easeOut + let duration = + Self.hudColorRollDuration + + Double(Self.resolveHexRollExtraLoops(index: index, targetDigit: targetDigit)) * 0.035 + let animation = CABasicAnimation(keyPath: "transform.translation.y") + animation.fromValue = fromY + animation.toValue = toY + animation.beginTime = CACurrentMediaTime() + stagger + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.fillMode = .both + animation.isRemovedOnCompletion = false + contentLayer.add(animation, forKey: Self.hudColorRollAnimationKey) + let columnState = HudHexPendingRollColumnState( + digits: contentDigits, + scrollsUp: scrollsUp, + contentLayer: contentLayer ) + return (columnState, stagger + duration) } - private func tumblerDigit(for targetDigit: String, index: Int, offset: Int) -> String { - let targetIndex = Self.hexWheel.firstIndex(of: Character(targetDigit)) ?? 0 - let wheelIndex = (targetIndex + index + offset) % Self.hexWheel.count - return String(Self.hexWheel[wheelIndex]) + private func addHudHexRollDigitStack( + to contentLayer: CALayer, + digits: [Character], + font: NSFont, + color: NSColor, + lineHeight: CGFloat, + digitWidth: CGFloat + ) { + for (row, digit) in digits.enumerated() { + let digitLayer = makeHudHexRollTextLayer( + text: String(digit), + font: font, + color: color, + frame: CGRect( + x: 0, + y: CGFloat(row) * lineHeight, + width: digitWidth, + height: lineHeight + ) + ) + contentLayer.addSublayer(digitLayer) + } + } + + private func removeHudHexRollLayerAnimations() { + hudHexRollLayer.removeAllAnimations() + for sublayer in hudHexRollLayer.sublayers ?? [] { + removeAnimationsRecursively(from: sublayer) + } + } + + private func removeAnimationsRecursively(from layer: CALayer) { + layer.removeAllAnimations() + for sublayer in layer.sublayers ?? [] { + removeAnimationsRecursively(from: sublayer) + } } private func makeHudHexRollTextLayer( @@ -2208,6 +2540,36 @@ final class LiveOverlayRenderer { return layer } + private func makeHudHexRollMultilineTextLayer( + text: String, + font: NSFont, + color: NSColor, + lineHeight: CGFloat, + frame: CGRect + ) -> CATextLayer { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + paragraphStyle.lineBreakMode = .byClipping + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight + let attributedString = NSAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + ] + ) + let layer = CATextLayer() + layer.contentsScale = hostView?.window?.backingScaleFactor ?? 2 + layer.string = attributedString + layer.alignmentMode = .left + layer.frame = frame + layer.isWrapped = true + layer.truncationMode = .none + return layer + } + private func addHudRollAnimation( to layer: CALayer, fromY: CGFloat, @@ -2238,13 +2600,12 @@ final class LiveOverlayRenderer { private func clearHudHexRollAnimation() { activeHudHexRollTarget = nil + activeHudHexRollSwatchColor = nil hudHexRollAnimationEndUptime = nil - hudHexRollLayer.removeAllAnimations() + hudHexPendingRollActive = false + hudHexPendingRollColumns.removeAll(keepingCapacity: true) + removeHudHexRollLayerAnimations() for sublayer in hudHexRollLayer.sublayers ?? [] { - sublayer.removeAllAnimations() - for childLayer in sublayer.sublayers ?? [] { - childLayer.removeAllAnimations() - } sublayer.removeFromSuperlayer() } hudHexRollLayer.isHidden = true @@ -2254,11 +2615,12 @@ final class LiveOverlayRenderer { lastHudColorPending = nil hudColorRevealArmed = true activeHudHexRollTarget = nil + activeHudHexRollSwatchColor = nil hudHexRollAnimationEndUptime = nil - hudSwatchLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) + hudHexPendingRollActive = false + hudHexPendingRollColumns.removeAll(keepingCapacity: true) hudSwatchLayer.removeAnimation(forKey: Self.hudColorResolveAnimationKey) hudSwatchLayer.removeAnimation(forKey: Self.hudColorResolveBackgroundAnimationKey) - hudHexLayer.removeAnimation(forKey: Self.hudColorPendingAnimationKey) hudHexLayer.removeAnimation(forKey: Self.hudColorResolveAnimationKey) hudHexLayer.isHidden = false clearHudHexRollAnimation() diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 657480d9..0674e1c0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -710,6 +710,10 @@ final class CaptureSessionController: NSObject { currentCaptureTelemetryID } + private func pointTelemetryDetail(_ point: CGPoint) -> String { + "x=\(Int(point.x.rounded())) y=\(Int(point.y.rounded()))" + } + func prepareLiveFrameStreamSampler(reason: String) { liveFrameStream.prepareSampler(reason: reason) } @@ -823,7 +827,6 @@ final class CaptureSessionController: NSObject { do { let startPoint = NSEvent.mouseLocation let desktopFrame = CaptureOverlayController.desktopFrame - let initialRgbSample: RGBSample? = nil frozenFrameLatchToken = nil // The Rust live sampler treats these IDs as current-process windows to // include through the app-level exclusion. Overlay windows must stay out @@ -831,6 +834,17 @@ final class CaptureSessionController: NSObject { pendingLiveFrameStreamRelease?.cancel() pendingLiveFrameStreamRelease = nil liveFrameStream.updateSelfCaptureExceptionWindowIDs(capturableOwnWindowIDs) + let warmStartedAt = ProcessInfo.processInfo.systemUptime + let initialSample = warmLiveSamplingIfPossible( + at: startPoint, + source: "start_capture", + captureID: captureID, + includedCurrentProcessWindowIDs: capturableOwnWindowIDs + ) + let initialRgbSample = + initialSample?.rgbSample + ?? frozenFrameAuthority.rgbSample(containing: startPoint) + let warmMilliseconds = NativeHostTelemetry.milliseconds(since: warmStartedAt) liveFrameStream.start( for: NSScreen.screens, prewarmPoint: startPoint, @@ -890,16 +904,27 @@ final class CaptureSessionController: NSObject { prewarmPoint: startPoint, captureID: captureID ) - _ = self.warmLiveSamplingIfPossible( - at: startPoint, - source: "capture_overlay_preflight", - captureID: captureID, - excludeSelfFromFrozenAuthority: true, - selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: capturableOwnWindowIDs - ) + if self.frozenFrameAuthority.hasSelfCaptureCompleteFrame( + containing: startPoint) + { + NativeHostTelemetry.captureEvent( + "capture.self_capture_rebuild_skipped", + captureID: captureID, + detail: "start_capture_complete_filter" + ) + } else { + _ = self.warmLiveSamplingIfPossible( + at: startPoint, + source: "capture_overlay_preflight", + captureID: captureID, + excludeSelfFromFrozenAuthority: true, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: capturableOwnWindowIDs + ) + } } ) + overlayController.prepareCaptureStreamsNow(trigger: "overlay_show") let overlayShowMilliseconds = NativeHostTelemetry.milliseconds(since: overlayShowStartedAt) (NSApp.delegate as? NativeHostApplicationController)?.window = @@ -910,7 +935,7 @@ final class CaptureSessionController: NSObject { NativeHostTelemetry.captureStartTiming( captureID: captureID, totalMilliseconds: NativeHostTelemetry.milliseconds(since: captureStartedAt), - warmMilliseconds: 0, + warmMilliseconds: warmMilliseconds, windowSnapshotMilliseconds: windowSnapshotMilliseconds, sessionSetupMilliseconds: sessionSetupMilliseconds, overlayShowMilliseconds: overlayShowMilliseconds, @@ -1114,6 +1139,11 @@ final class CaptureSessionController: NSObject { overlayController?.markLivePrimaryInteractionReleased(at: point) do { + NativeHostTelemetry.captureEvent( + "capture.live_primary_complete_requested", + captureID: currentCaptureTelemetryID, + detail: pointTelemetryDetail(point) + ) liveFrameStream.prime(at: point) if frozenFrameLatchToken == nil { frozenFrameLatchToken = frozenFrameAuthority.latchToken(containing: point) @@ -1135,6 +1165,11 @@ final class CaptureSessionController: NSObject { ) ) try syncCore() + NativeHostTelemetry.captureEvent( + "capture.live_primary_complete_synced", + captureID: currentCaptureTelemetryID, + detail: "mode=\(scene.mode)" + ) if scene.mode == .live { if pendingFrozenCommit == nil { chromeState.endHostLocalFrozenSelecting() @@ -1700,6 +1735,12 @@ final class CaptureSessionController: NSObject { case .stopLiveCapture: tearDownCapture() case .requestFreezeSnapshot(let selection, let selectionEditable): + NativeHostTelemetry.captureEvent( + "capture.freeze_snapshot_requested", + captureID: currentCaptureTelemetryID, + detail: + "editable=\(selectionEditable) x=\(Int(selection.minX.rounded())) y=\(Int(selection.minY.rounded())) w=\(Int(selection.width.rounded())) h=\(Int(selection.height.rounded()))" + ) try commitFrozenSelection( selection, editable: selectionEditable @@ -2993,19 +3034,19 @@ final class CaptureSessionController: NSObject { fileprivate func releaseScreenCaptureStreams(immediate: Bool = false) { pendingLiveFrameStreamRelease?.cancel() pendingLiveFrameStreamRelease = nil - frozenFrameAuthority.stop() - let releaseLiveFrameStream = { [weak self] in + let releaseScreenCaptureStreams = { [weak self] in guard let self else { return } + self.frozenFrameAuthority.stop() self.liveFrameStream.stop() self.pendingLiveFrameStreamRelease = nil } if immediate { - releaseLiveFrameStream() + releaseScreenCaptureStreams() return } - let workItem = DispatchWorkItem(block: releaseLiveFrameStream) + let workItem = DispatchWorkItem(block: releaseScreenCaptureStreams) pendingLiveFrameStreamRelease = workItem DispatchQueue.main.asyncAfter( deadline: .now() + Self.liveFrameStreamReleaseGrace, @@ -4058,7 +4099,7 @@ final class CaptureOverlayWindow: NSPanel { isMovable = false isOpaque = false level = .screenSaver - sharingType = .none + sharingType = .readOnly titleVisibility = .hidden titlebarAppearsTransparent = true } @@ -4261,6 +4302,7 @@ final class CaptureHostView: NSView { placeholderYSlotWidth: "y=?".size(using: font).width ) }() + private static let pendingHudHexWheel = Array("0123456789ABCDEF") weak var controller: CaptureSessionController? @@ -4302,6 +4344,10 @@ final class CaptureHostView: NSView { private var liveDragExceededThreshold = false private var livePrimaryCompletionInFlight = false private var liveMouseUpMonitor: Any? + private var liveGlobalMouseUpMonitor: Any? + private var liveMouseUpEventTap: CFMachPort? + private var liveMouseUpEventTapRunLoopSource: CFRunLoopSource? + private var liveMouseReleaseWatchdog: DispatchWorkItem? private var livePointerPreviewGlobal: CGPoint? private var livePointerPreviewInputUptime: TimeInterval? private var livePointerPreviewInputSequence: UInt64 = 0 @@ -4722,8 +4768,11 @@ final class CaptureHostView: NSView { if recoverReleasedLivePrimaryInteractionIfNeeded(at: point) { return } - if liveDragDistance(from: point) >= Self.liveDragIntentThreshold { + if !liveDragExceededThreshold, + liveDragDistance(from: point) >= Self.liveDragIntentThreshold + { liveDragExceededThreshold = true + logLivePrimaryInputEvent("capture.live_primary_drag_threshold", point: point) } updateLivePointerPreview(to: point, rendersImmediately: false) queuePointerEvent(liveDragExceededThreshold ? .liveDragged(point) : .moved(point)) @@ -4745,8 +4794,10 @@ final class CaptureHostView: NSView { liveDragReleasedGlobal = nil liveDragExceededThreshold = false livePrimaryCompletionInFlight = false + logLivePrimaryInputEvent("capture.live_primary_mouse_down", point: point) controller?.registerLivePrimaryInteractionOwner(self) installLiveMouseUpMonitor() + installLiveMouseReleaseWatchdog() updateLivePointerPreview(to: point, rendersImmediately: true) controller?.beginPrimaryInteraction(at: point) case .frozen: @@ -4795,6 +4846,7 @@ final class CaptureHostView: NSView { override func mouseUp(with event: NSEvent) { let point = globalPoint(from: event) if scene.mode == .live { + logLivePrimaryInputEvent("capture.live_primary_mouse_up", point: point) controller?.completeLivePrimaryInteraction(from: self, at: point) } else if scene.mode == .frozen { controller?.completeFrozenInteraction(at: point) @@ -5276,6 +5328,11 @@ final class CaptureHostView: NSView { return } let completionPoint = liveDragCompletionPoint(for: point) + logLivePrimaryInputEvent( + "capture.live_primary_release_marked", + point: completionPoint, + detail: "dragExceeded=\(liveDragExceededThreshold)" + ) livePrimaryCompletionInFlight = true liveDragReleasedGlobal = completionPoint liveHoverChromeSuppressed = false @@ -5297,6 +5354,11 @@ final class CaptureHostView: NSView { return } let completionPoint = liveDragCompletionPoint(for: point) + logLivePrimaryInputEvent( + "capture.live_primary_complete_owned", + point: completionPoint, + detail: "dragExceeded=\(liveDragExceededThreshold)" + ) markLivePrimaryInteractionReleased(at: point) if let controller { controller.completePrimaryInteraction(at: completionPoint) @@ -5315,6 +5377,7 @@ final class CaptureHostView: NSView { else { return false } + logLivePrimaryInputEvent("capture.live_primary_release_recovered", point: point) controller?.completeLivePrimaryInteraction(from: self, at: point) return true } @@ -5344,27 +5407,183 @@ final class CaptureHostView: NSView { removeLiveMouseUpMonitor() liveMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in - guard let self else { - return event - } - if self.scene.mode == .live, - self.liveDragStartGlobal != nil, - !self.livePrimaryCompletionInFlight - { - self.controller?.completeLivePrimaryInteraction( - from: self, - at: self.globalPoint(fromAnyEvent: event) + self?.completeLivePrimaryInteractionFromMouseUp(event, source: "local") + return event + } + liveGlobalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp]) { + [weak self] event in + let point = NSEvent.mouseLocation + DispatchQueue.main.async { + self?.completeLivePrimaryInteractionFromMouseUp( + event, + source: "global", + fallbackPoint: point ) } - return event } + installLiveMouseUpEventTap() } private func removeLiveMouseUpMonitor() { + cancelLiveMouseReleaseWatchdog() if let liveMouseUpMonitor { NSEvent.removeMonitor(liveMouseUpMonitor) self.liveMouseUpMonitor = nil } + if let liveGlobalMouseUpMonitor { + NSEvent.removeMonitor(liveGlobalMouseUpMonitor) + self.liveGlobalMouseUpMonitor = nil + } + removeLiveMouseUpEventTap() + } + + private func completeLivePrimaryInteractionFromMouseUp( + _ event: NSEvent, + source: String, + fallbackPoint: CGPoint? = nil + ) { + completeLivePrimaryInteractionFromSystemMouseUp( + at: fallbackPoint ?? globalPoint(fromAnyEvent: event), + source: source + ) + } + + private func completeLivePrimaryInteractionFromSystemMouseUp( + at point: CGPoint, + source: String + ) { + guard + scene.mode == .live, + liveDragStartGlobal != nil, + !livePrimaryCompletionInFlight + else { + return + } + logLivePrimaryInputEvent( + "capture.live_primary_mouse_up_monitor", + point: point, + detail: "source=\(source)" + ) + controller?.completeLivePrimaryInteraction( + from: self, + at: point + ) + } + + private func installLiveMouseUpEventTap() { + guard liveMouseUpEventTap == nil else { + return + } + let mask = CGEventMask(1 << CGEventType.leftMouseUp.rawValue) + let refcon = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + guard + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: Self.liveMouseUpEventTapCallback, + userInfo: refcon + ), + let source = CFMachPortCreateRunLoopSource(nil, eventTap, 0) + else { + NativeHostTelemetry.captureEvent( + "capture.live_primary_mouse_up_event_tap", + captureID: controller?.activeTelemetryCaptureID ?? 0, + outcome: "unavailable" + ) + return + } + liveMouseUpEventTap = eventTap + liveMouseUpEventTapRunLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + private func removeLiveMouseUpEventTap() { + if let source = liveMouseUpEventTapRunLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + liveMouseUpEventTapRunLoopSource = nil + } + if let eventTap = liveMouseUpEventTap { + CFMachPortInvalidate(eventTap) + liveMouseUpEventTap = nil + } + } + + private static let liveMouseUpEventTapCallback: CGEventTapCallBack = { + _, type, event, userInfo in + guard type == .leftMouseUp, let userInfo else { + return Unmanaged.passUnretained(event) + } + let view = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + let point = view.appKitPoint(fromQuartzPoint: event.location) + DispatchQueue.main.async { + view.completeLivePrimaryInteractionFromSystemMouseUp( + at: point, + source: "event_tap" + ) + } + return Unmanaged.passUnretained(event) + } + + private func appKitPoint(fromQuartzPoint point: CGPoint) -> CGPoint { + let desktopFrame = CaptureOverlayController.desktopFrame + return CGPoint(x: point.x, y: desktopFrame.maxY - point.y) + } + + private func installLiveMouseReleaseWatchdog() { + cancelLiveMouseReleaseWatchdog() + scheduleLiveMouseReleaseWatchdog() + } + + private func scheduleLiveMouseReleaseWatchdog() { + let workItem = DispatchWorkItem { [weak self] in + self?.pollLiveMouseReleaseWatchdog() + } + liveMouseReleaseWatchdog = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + + NativeHostDisplayRefresh.frameInterval( + forTargetFramesPerSecond: NativeHostDisplayRefresh.maximumTargetFramesPerSecond), + execute: workItem + ) + } + + private func pollLiveMouseReleaseWatchdog() { + liveMouseReleaseWatchdog = nil + guard + scene.mode == .live, + liveDragStartGlobal != nil, + !livePrimaryCompletionInFlight + else { + return + } + if !isPrimaryMouseButtonPressed() { + let point = NSEvent.mouseLocation + logLivePrimaryInputEvent("capture.live_primary_release_watchdog", point: point) + completeLivePrimaryInteractionFromSystemMouseUp(at: point, source: "watchdog") + return + } + scheduleLiveMouseReleaseWatchdog() + } + + private func logLivePrimaryInputEvent( + _ event: String, + point: CGPoint, + detail: String = "none" + ) { + NativeHostTelemetry.captureEvent( + event, + captureID: controller?.activeTelemetryCaptureID ?? 0, + detail: + "\(detail) x=\(Int(point.x.rounded())) y=\(Int(point.y.rounded())) inFlight=\(livePrimaryCompletionInFlight)" + ) + } + + private func cancelLiveMouseReleaseWatchdog() { + liveMouseReleaseWatchdog?.cancel() + liveMouseReleaseWatchdog = nil } private func cancelQueuedPointerDispatch() { @@ -7033,10 +7252,9 @@ final class CaptureHostView: NSView { } private func currentLiveColorDisplay(for sample: RGBSample?) -> LiveColorDisplay { - let placeholderHex = "#000000" let hexText = sample.map { String(format: "#%02X%02X%02X", $0.r, $0.g, $0.b) } - ?? placeholderHex + ?? pendingLiveColorHexText() return LiveColorDisplay( hexText: hexText, hexSlotWidth: Self.hudLayoutMetrics.hexSlotWidth, @@ -7044,6 +7262,21 @@ final class CaptureHostView: NSView { ) } + private func pendingLiveColorHexText() -> String { + let uptime = ProcessInfo.processInfo.systemUptime + let digits = (0..<6).map { index -> Character in + let rate = 9 + ((index * 7) % 6) + let phase = Double((index * 23) % 31) / 31.0 + let tick = Int(((uptime + phase) * Double(rate)).rounded(.down)) + var seed = + UInt64(tick + 1) &* 1_099_511_628_211 + ^ UInt64(index + 1) &* 0x9E37_79B9_7F4A_7C15 + seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return Self.pendingHudHexWheel[Int((seed >> 58) & 0xF)] + } + return "#" + String(digits) + } + private func drawPill( in frame: CGRect, context: CGContext, diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostDisplayRefresh.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostDisplayRefresh.swift index 1be7826d..60741252 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostDisplayRefresh.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostDisplayRefresh.swift @@ -20,7 +20,7 @@ enum NativeHostDisplayRefresh { } static func pointerFollowFramesPerSecond(for screen: NSScreen?) -> Int { - targetFramesPerSecond(for: screen) + maximumTargetFramesPerSecond } static func samplingFramesPerSecond() -> Int { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift index f2470892..14a718fe 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift @@ -155,7 +155,7 @@ struct NativeHostSettings: Equatable { hudTintHue: 0.6074879184861536, hudTintSaturation: 0.72, hudTintBrightness: 0.95, - liquidGlassStyle: .clear, + liquidGlassStyle: .regular, loupeSampleSize: .small ) } @@ -382,14 +382,14 @@ enum LoupeSampleSizePreference: String, CaseIterable { } enum LiveChromeGlassMaterialSupport { - static var isLiquidGlassAvailable: Bool { + static let isLiquidGlassAvailable: Bool = { #if compiler(>=6.2) if #available(macOS 26.0, *) { return true } #endif return false - } + }() } extension NativeHostSettings { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index f6e9b62b..087e817b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -723,7 +723,7 @@ private struct HudGlassModePicker: View { ) { onSelect(mode) } - .help(available ? mode.title : "Requires Liquid Glass support.") + .help(available ? mode.title : "Requires macOS 26.") } } .padding(.horizontal, 1) @@ -1015,7 +1015,7 @@ private struct AppearanceSettingsPanel: View { private var materialSubtitle: String { LiveChromeGlassMaterialSupport.isLiquidGlassAvailable ? "Liquid or blur." - : "Classic fallback." + : "Classic fallback before macOS 26." } private var tintColorBinding: Binding { diff --git a/scripts/smoke/lib/live-hud-mouse-path.swift b/scripts/smoke/lib/live-hud-mouse-path.swift index 9e06960b..7360afca 100644 --- a/scripts/smoke/lib/live-hud-mouse-path.swift +++ b/scripts/smoke/lib/live-hud-mouse-path.swift @@ -106,6 +106,14 @@ func writeMaskProbePhase(_ phase: String) { } } +func releasePrimaryButton(with driver: MousePathDriver, at point: CGPoint) { + driver.mouseEvent(.leftMouseUp, at: point) + sleepMs(20) + driver.mouseEvent(.leftMouseUp, at: point) + sleepMs(20) + driver.mouseEvent(.mouseMoved, at: point) +} + func moveSmooth(points: [CGPoint], durationMs: Int, rateHz: Int, cycles: Int) { let driver = MousePathDriver() let minX = points.map(\.x).min() ?? 0 @@ -160,7 +168,7 @@ func dragRegion(points: [CGPoint], durationMs: Int, rateHz: Int) { writeMaskProbePhase("holding") sleepMs(useconds_t(holdBeforeReleaseMs)) } - driver.mouseEvent(.leftMouseUp, at: end) + releasePrimaryButton(with: driver, at: end) writeMaskProbePhase("released") if ProcessInfo.processInfo.environment["MASK_PROBE_PHASE_PATH"] != nil { sleepMs(useconds_t(readInt("MASK_PROBE_POST_RELEASE_MS", default: 360))) @@ -174,7 +182,12 @@ func clickPoint(points: [CGPoint]) { sleepMs(120) driver.mouseEvent(.leftMouseDown, at: point) sleepMs(24) - driver.mouseEvent(.leftMouseUp, at: point) + releasePrimaryButton(with: driver, at: point) +} + +func releasePrimaryButton(points: [CGPoint]) { + let driver = MousePathDriver() + releasePrimaryButton(with: driver, at: points[0]) } let points = readPoints("PATH_POINTS") @@ -194,6 +207,8 @@ case "waypoints": delayMs: useconds_t(readInt("PATH_STEP_DELAY_MS", default: 10)), cycles: readInt("PATH_CYCLES", default: 2) ) +case "release-primary": + releasePrimaryButton(points: points) default: moveSmooth( points: points, diff --git a/scripts/smoke/lib/live-hud.sh b/scripts/smoke/lib/live-hud.sh index 5326a6a3..23700b99 100644 --- a/scripts/smoke/lib/live-hud.sh +++ b/scripts/smoke/lib/live-hud.sh @@ -115,3 +115,10 @@ live_hud_run_mouse_path() { PATH_CYCLES="$PATH_CYCLES" \ swift "$(live_hud_cursor_helper)" } + +live_hud_release_primary_button() { + PATH_POINTS="$PATH_POINTS" \ + PATH_MODE="release-primary" \ + PATH_DRIVER="$PATH_DRIVER" \ + swift "$(live_hud_cursor_helper)" +} diff --git a/scripts/smoke/lib/mask-probe-capture.swift b/scripts/smoke/lib/mask-probe-capture.swift index 18c28311..9ce737e5 100644 --- a/scripts/smoke/lib/mask-probe-capture.swift +++ b/scripts/smoke/lib/mask-probe-capture.swift @@ -51,6 +51,7 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { private let readyPath: String? private let screenshotPath: String? private let releasedScreenshotPath: String? + private let screenshotDelaySeconds: TimeInterval private let point: CGPoint private let displayFrame: CGRect private let lock = NSLock() @@ -59,6 +60,8 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { private var wroteScreenshot = false private var wroteReleasedScreenshot = false private var completedReleasedScreenshot = false + private var observedPhase = "" + private var observedPhaseStartedAt: TimeInterval = 0 init( outputPath: String, @@ -66,6 +69,7 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { readyPath: String?, screenshotPath: String?, releasedScreenshotPath: String?, + screenshotDelayMilliseconds: Int, point: CGPoint, displayFrame: CGRect ) { @@ -74,6 +78,8 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { self.readyPath = readyPath self.screenshotPath = screenshotPath self.releasedScreenshotPath = releasedScreenshotPath + self.screenshotDelaySeconds = + TimeInterval(max(0, screenshotDelayMilliseconds)) / 1_000.0 self.point = point self.displayFrame = displayFrame } @@ -91,12 +97,16 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { return } let phase = currentPhase() + let now = ProcessInfo.processInfo.systemUptime let screenshotToWrite: (path: String, phase: String)? lock.lock() - if phase == "holding", let screenshotPath, !wroteScreenshot { + let phaseIsStable = updateObservedPhase(phase, now: now) + if phase == "holding", let screenshotPath, !wroteScreenshot, phaseIsStable { wroteScreenshot = true screenshotToWrite = (screenshotPath, phase) - } else if phase == "released", let releasedScreenshotPath, !wroteReleasedScreenshot { + } else if phase == "released", let releasedScreenshotPath, !wroteReleasedScreenshot, + phaseIsStable + { wroteReleasedScreenshot = true screenshotToWrite = (releasedScreenshotPath, phase) } else { @@ -105,7 +115,7 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { samples.append( Sample( phase: phase, - uptime: ProcessInfo.processInfo.systemUptime, + uptime: now, luminance: luminance ) ) @@ -121,6 +131,15 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { } } + private func updateObservedPhase(_ phase: String, now: TimeInterval) -> Bool { + guard phase == observedPhase else { + observedPhase = phase + observedPhaseStartedAt = now + return screenshotDelaySeconds <= 0 + } + return now - observedPhaseStartedAt >= screenshotDelaySeconds + } + func writeSamples() { lock.lock() let samples = self.samples @@ -263,6 +282,7 @@ func runMaskProbe() async throws { let readyPath = readOptionalString("MASK_PROBE_READY_PATH") let screenshotPath = readOptionalString("MASK_PROBE_SCREENSHOT_PATH") let releasedScreenshotPath = readOptionalString("MASK_PROBE_RELEASED_SCREENSHOT_PATH") + let screenshotDelayMs = readInt("MASK_PROBE_SCREENSHOT_DELAY_MS", default: 120) let point = readRequiredPoint("MASK_PROBE_POINT") let durationMs = readInt("MASK_PROBE_DURATION_MS", default: 1_400) let rateHz = readInt("MASK_PROBE_RATE_HZ", default: 60) @@ -293,6 +313,7 @@ func runMaskProbe() async throws { readyPath: readyPath, screenshotPath: screenshotPath, releasedScreenshotPath: releasedScreenshotPath, + screenshotDelayMilliseconds: screenshotDelayMs, point: point, displayFrame: display.frame ) diff --git a/scripts/smoke/lib/native-visual-contract-summary.py b/scripts/smoke/lib/native-visual-contract-summary.py index f7e08c49..996ad667 100644 --- a/scripts/smoke/lib/native-visual-contract-summary.py +++ b/scripts/smoke/lib/native-visual-contract-summary.py @@ -558,12 +558,14 @@ def line_epoch(line: str) -> float | None: failures.append(f"visual screenshot suspiciously small: {size} bytes") if mask_probe_path: - try: - with open(mask_probe_path, "r", encoding="utf-8", errors="replace") as handle: - rows = [line.strip().split(",") for line in handle if line.strip()] - except OSError as exc: - failures.append(f"mask probe missing: {exc}") - else: + for probe_path in [path for path in mask_probe_path.split(":") if path]: + try: + with open(probe_path, "r", encoding="utf-8", errors="replace") as handle: + rows = [line.strip().split(",") for line in handle if line.strip()] + except OSError as exc: + failures.append(f"mask probe missing: {exc}") + continue + values: dict[str, list[float]] = {"dragging": [], "released": []} for row in rows[1:]: if len(row) != 3: @@ -581,36 +583,37 @@ def line_epoch(line: str) -> float | None: failures.append( f"mask probe sample count too small: dragging={len(dragging)} released={len(released)}" ) - else: - stable_dragging = dragging[len(dragging) // 3 :] - baseline = statistics.median(stable_dragging) - released_min = min(released) - released_max = max(released) - rise = released_max - baseline - drop = baseline - released_min - ratio = released_max / max(0.05, baseline) - drop_ratio = baseline / max(0.05, released_min) - max_rise = threshold("MAX_MASK_LUMINANCE_RISE", 0.12) - max_drop = threshold("MAX_MASK_LUMINANCE_DROP", 0.12) - max_ratio = threshold("MAX_MASK_LUMINANCE_RATIO", 1.35) - print( - "[smoke] mask_probe " - f"path={mask_probe_path} baseline={baseline:.4f} " - f"releasedMin={released_min:.4f} releasedMax={released_max:.4f} " - f"drop={drop:.4f} rise={rise:.4f} " - f"dropRatio={drop_ratio:.2f} riseRatio={ratio:.2f} " - f"draggingSamples={len(dragging)} releasedSamples={len(released)}" + continue + + stable_dragging = dragging[len(dragging) // 3 :] + baseline = statistics.median(stable_dragging) + released_min = min(released) + released_max = max(released) + rise = released_max - baseline + drop = baseline - released_min + ratio = released_max / max(0.05, baseline) + drop_ratio = baseline / max(0.05, released_min) + max_rise = threshold("MAX_MASK_LUMINANCE_RISE", 0.12) + max_drop = threshold("MAX_MASK_LUMINANCE_DROP", 0.12) + max_ratio = threshold("MAX_MASK_LUMINANCE_RATIO", 1.35) + print( + "[smoke] mask_probe " + f"path={probe_path} baseline={baseline:.4f} " + f"releasedMin={released_min:.4f} releasedMax={released_max:.4f} " + f"drop={drop:.4f} rise={rise:.4f} " + f"dropRatio={drop_ratio:.2f} riseRatio={ratio:.2f} " + f"draggingSamples={len(dragging)} releasedSamples={len(released)}" + ) + if rise > max_rise and ratio > max_ratio: + failures.append( + "live-to-frozen handoff let the outside-selection scrim brighten " + f"(rise={rise:.4f}, ratio={ratio:.2f})" + ) + if drop > max_drop and drop_ratio > max_ratio: + failures.append( + "live-to-frozen handoff double-darkened the outside-selection scrim " + f"(drop={drop:.4f}, ratio={drop_ratio:.2f})" ) - if rise > max_rise and ratio > max_ratio: - failures.append( - "live-to-frozen handoff let the outside-selection scrim brighten " - f"(rise={rise:.4f}, ratio={ratio:.2f})" - ) - if drop > max_drop and drop_ratio > max_ratio: - failures.append( - "live-to-frozen handoff double-darkened the outside-selection scrim " - f"(drop={drop:.4f}, ratio={drop_ratio:.2f})" - ) if failures: for failure in failures: diff --git a/scripts/smoke/lib/replay-scroll-capture.sh b/scripts/smoke/lib/replay-scroll-capture.sh index c2eeb426..cb4f32dc 100644 --- a/scripts/smoke/lib/replay-scroll-capture.sh +++ b/scripts/smoke/lib/replay-scroll-capture.sh @@ -70,6 +70,45 @@ replay_scroll_capture_assert_self_check_only() { return 0 } +replay_scroll_capture_has_trace_arg() { + local arg + for arg in "$@"; do + if [[ "$arg" == "--trace" ]]; then + return 0 + fi + done + + return 1 +} + +replay_scroll_capture_default_trace_root() { + case "$(uname -s)" in + Darwin) + printf '%s/Library/Application Support/ink.hack.rsnap/scroll-capture-traces\n' "$HOME" + ;; + *) + printf '%s/.local/share/ink.hack.rsnap/scroll-capture-traces\n' "$HOME" + ;; + esac +} + +replay_scroll_capture_maybe_skip_without_traces() { + replay_scroll_capture_has_trace_arg "$@" && return 0 + replay_scroll_capture_has_flag "--list" "$@" && return 0 + + local trace_root latest_manifest + trace_root="$(replay_scroll_capture_default_trace_root)" + latest_manifest="$( + find "$trace_root" -mindepth 2 -maxdepth 2 -name manifest.json -print -quit 2>/dev/null || true + )" + if [[ -n "$latest_manifest" ]]; then + return 0 + fi + + printf '[replay] SKIP no recorded scroll-capture traces under %s; pass --trace to replay a specific trace.\n' "$trace_root" + return 1 +} + replay_scroll_capture_run() { local mode="$1" shift @@ -97,6 +136,10 @@ replay_scroll_capture_run() { ;; esac + if ! replay_scroll_capture_maybe_skip_without_traces "$@"; then + return 0 + fi + cd "$(replay_scroll_capture_repo_root)" exec cargo run -p rsnap-overlay --example scroll_capture_replay -- \ --force-worker-pairwise \ diff --git a/scripts/smoke/native-hud-follow-macos.sh b/scripts/smoke/native-hud-follow-macos.sh index cb084ee7..b1216a4c 100755 --- a/scripts/smoke/native-hud-follow-macos.sh +++ b/scripts/smoke/native-hud-follow-macos.sh @@ -22,6 +22,7 @@ Useful overrides: PATH_DURATION_MS=2500 smooth path duration PATH_CYCLES=3 smooth path lissajous cycles HUD_FOLLOW_CASES=hud,loupe run collapsed HUD and expanded loupe cases + LOUPE_TOGGLE_SETTLE_S=0.8 settle after Tab before measuring loupe follow MAX_SAMPLE_REFRESH_GAP_P95_MS default: pointer/sample target budget + 1ms MAX_ACTIVE_LAYER_CHROME_RENDER_GAP_P95_MS default: active display target budget + 1ms MAX_LAYER_CHROME_RENDER_DURATION_P95_MS default: active display target budget @@ -62,6 +63,7 @@ PATH_RATE_HZ="${USER_PATH_RATE_HZ:-120}" PATH_CYCLES="${USER_PATH_CYCLES:-3}" HUD_FOLLOW_CASES="${USER_HUD_FOLLOW_CASES:-hud,loupe}" OVERLAY_SETTLE_S="${OVERLAY_SETTLE_S:-0.35}" +LOUPE_TOGGLE_SETTLE_S="${LOUPE_TOGGLE_SETTLE_S:-0.8}" POST_PATH_SETTLE_S="${POST_PATH_SETTLE_S:-0.6}" POST_CLOSE_SETTLE_S="${POST_CLOSE_SETTLE_S:-0.25}" RSNAP_TELEMETRY_LAST="${RSNAP_TELEMETRY_LAST:-10s}" @@ -92,12 +94,16 @@ run_hud_follow_case() { "$ROOT_DIR/scripts/build_and_run.sh" verify >/tmp/rsnap-native-hud-follow-build.out sleep 1.0 + live_hud_release_primary_button + sleep 0.05 press_capture_hotkey sleep 0.4 press_capture_hotkey sleep 0.2 live_hud_focus_rsnap_overlay sleep "$OVERLAY_SETTLE_S" + live_hud_release_primary_button + sleep 0.05 case "$case_name" in hud) @@ -108,6 +114,7 @@ run_hud_follow_case() { echo "[smoke] case: loupe" live_hud_focus_rsnap_overlay live_hud_press_tab + sleep "$LOUPE_TOGGLE_SETTLE_S" live_hud_run_mouse_path ;; "") diff --git a/scripts/smoke/native-visual-contract-macos.sh b/scripts/smoke/native-visual-contract-macos.sh index f6611354..25c69aa5 100755 --- a/scripts/smoke/native-visual-contract-macos.sh +++ b/scripts/smoke/native-visual-contract-macos.sh @@ -12,14 +12,14 @@ Usage: native-visual-contract-macos.sh [--self-check] [--help] Runs the native macOS visual/behavior contract smoke: 1. force a representative native-host visual mode 2. build, sign, and launch the native host app - 3. run one real click freeze and one real held-drag freeze + 3. run real held-drag freeze coverage before click/frozen immutability guards 4. capture the in-drag and frozen overlays 5. gate click/drag editability, scrim stability, border leakage, and handoff telemetry Useful overrides: VISUAL_CONTRACT_CASES=liquid optional: liquid,classic REPEATED_CLICK_FREEZES=1 - VERIFY_CLICK_FROZEN_MOVE=1 attempts a forbidden fixed-selection frozen move + VERIFY_CLICK_FROZEN_MOVE=0 optional: after click freeze, attempts a forbidden fixed-selection frozen move REPEATED_DRAG_FREEZES=2 DRAG_DURATION_MS=260 DRAG_HOLD_BEFORE_RELEASE_MS=700 @@ -28,12 +28,13 @@ Useful overrides: APP_POST_VERIFY_SETTLE_S=0 OVERLAY_SETTLE_S=0.08 POST_FREEZE_SETTLE_S=0.08 - POST_CLOSE_SETTLE_S=0.12 + POST_CLOSE_SETTLE_S=0.18 MASK_PROBE_MIN_PHASE_SAMPLES=5 MASK_PROBE_POLL_MS=20 MASK_PROBE_POST_RELEASE_MS=180 MASK_PROBE_STOP_CAPTURE=0 - VISUAL_PROBE_ALL_DRAGS=0 + MASK_PROBE_SCREENSHOT_DELAY_MS=120 + VISUAL_PROBE_ALL_DRAGS=1 MAX_FREEZE_COMMIT_MS=90 MAX_FREEZE_PRESENT_MS=35 MAX_FREEZE_SNAPSHOT_WAIT_MS=45 @@ -76,15 +77,16 @@ VISUAL_BACKGROUND_MODE="${VISUAL_BACKGROUND_MODE:-none}" APP_POST_VERIFY_SETTLE_S="${APP_POST_VERIFY_SETTLE_S:-0}" OVERLAY_SETTLE_S="${OVERLAY_SETTLE_S:-0.08}" POST_FREEZE_SETTLE_S="${POST_FREEZE_SETTLE_S:-0.08}" -POST_CLOSE_SETTLE_S="${POST_CLOSE_SETTLE_S:-0.12}" +POST_CLOSE_SETTLE_S="${POST_CLOSE_SETTLE_S:-0.18}" REPEATED_CLICK_FREEZES="${REPEATED_CLICK_FREEZES:-1}" -VERIFY_CLICK_FROZEN_MOVE="${VERIFY_CLICK_FROZEN_MOVE:-1}" +VERIFY_CLICK_FROZEN_MOVE="${VERIFY_CLICK_FROZEN_MOVE:-0}" REPEATED_DRAG_FREEZES="${REPEATED_DRAG_FREEZES:-2}" MASK_PROBE_MIN_PHASE_SAMPLES="${MASK_PROBE_MIN_PHASE_SAMPLES:-5}" MASK_PROBE_POLL_MS="${MASK_PROBE_POLL_MS:-20}" MASK_PROBE_POST_RELEASE_MS="${MASK_PROBE_POST_RELEASE_MS:-180}" MASK_PROBE_STOP_CAPTURE="${MASK_PROBE_STOP_CAPTURE:-0}" -VISUAL_PROBE_ALL_DRAGS="${VISUAL_PROBE_ALL_DRAGS:-0}" +MASK_PROBE_SCREENSHOT_DELAY_MS="${MASK_PROBE_SCREENSHOT_DELAY_MS:-120}" +VISUAL_PROBE_ALL_DRAGS="${VISUAL_PROBE_ALL_DRAGS:-1}" PREF_DOMAIN="${RSNAP_PREF_DOMAIN:-ink.hack.rsnap}" PREF_SNAPSHOT="$(mktemp "${TMPDIR:-/tmp}/rsnap-prefs.XXXXXX.plist")" @@ -118,6 +120,18 @@ end tell APPLESCRIPT } +open_capture_overlay() { + press_capture_hotkey + sleep "$OVERLAY_SETTLE_S" + live_hud_focus_rsnap_overlay +} + +close_capture_overlay() { + live_hud_focus_rsnap_overlay >/dev/null 2>&1 || true + live_hud_press_escape >/dev/null 2>&1 || true + sleep "$POST_CLOSE_SETTLE_S" +} + start_visual_background() { local ready_path="$1" rm -f "$ready_path" @@ -210,8 +224,9 @@ wait_file_nonempty() { if [[ -z "$DISPLAY_BOUNDS" ]]; then DISPLAY_BOUNDS="$(live_hud_read_main_display_bounds | tr -d ' ')" fi -if [[ -z "$PATH_POINTS" ]]; then - PATH_POINTS="$( +DRAG_POINTS="${PATH_POINTS:-}" +if [[ -z "$DRAG_POINTS" ]]; then + DRAG_POINTS="$( python3 - "$DISPLAY_BOUNDS" <<'PY' import sys @@ -229,7 +244,7 @@ PY fi smoke_log "display bounds: $DISPLAY_BOUNDS" -smoke_log "drag points: $PATH_POINTS duration_ms=$DRAG_DURATION_MS hold_ms=$DRAG_HOLD_BEFORE_RELEASE_MS rate_hz=$PATH_RATE_HZ" +smoke_log "drag points: $DRAG_POINTS duration_ms=$DRAG_DURATION_MS hold_ms=$DRAG_HOLD_BEFORE_RELEASE_MS rate_hz=$PATH_RATE_HZ" CLICK_POINTS="$( python3 - "$DISPLAY_BOUNDS" <<'PY' @@ -298,6 +313,7 @@ run_visual_case() { swiftc "$SCRIPT_DIR/lib/mask-probe-capture.swift" -o "$mask_probe_bin" smoke_log "compiled visual smoke helpers" drag_screenshot_paths="" + mask_probe_paths="" drag_screenshot_path="$case_tmp_dir/dragging-1.png" screenshot_path="$case_tmp_dir/frozen-1.png" mask_probe_path="" @@ -313,34 +329,7 @@ run_visual_case() { "$ROOT_DIR/scripts/build_and_run.sh" verify >/tmp/rsnap-native-visual-contract-build.out smoke_log "native host verified" sleep "$APP_POST_VERIFY_SETTLE_S" - live_hud_press_escape >/dev/null 2>&1 || true - - for ((click_index = 1; click_index <= REPEATED_CLICK_FREEZES; click_index++)); do - smoke_log "repeated click freeze $click_index/$REPEATED_CLICK_FREEZES" - press_capture_hotkey - sleep "$OVERLAY_SETTLE_S" - live_hud_focus_rsnap_overlay - PATH_MODE=click-point \ - PATH_DRIVER=event \ - PATH_POINTS="$CLICK_POINTS" \ - "$cursor_helper_bin" - sleep "$POST_FREEZE_SETTLE_S" - live_hud_focus_rsnap_overlay - if [[ "$VERIFY_CLICK_FROZEN_MOVE" == "1" ]]; then - PATH_MODE=drag-region \ - PATH_DRIVER=event \ - PATH_POINTS="$CLICK_FROZEN_MOVE_POINTS" \ - PATH_DURATION_MS=90 \ - PATH_RATE_HZ=120 \ - PATH_HOLD_BEFORE_RELEASE_MS=0 \ - "$cursor_helper_bin" - sleep "$POST_FREEZE_SETTLE_S" - live_hud_focus_rsnap_overlay - smoke_log "click $click_index attempted forbidden frozen move" - fi - live_hud_press_escape >/dev/null 2>&1 || true - sleep "$POST_CLOSE_SETTLE_S" - done + close_capture_overlay for ((drag_index = 1; drag_index <= REPEATED_DRAG_FREEZES; drag_index++)); do smoke_log "repeated drag freeze $drag_index/$REPEATED_DRAG_FREEZES" @@ -370,7 +359,7 @@ run_visual_case() { printf 'pre' >"$mask_probe_phase_path" rm -f "$mask_probe_ready_path" mask_probe_point="$( - python3 - "$DISPLAY_BOUNDS" "$PATH_POINTS" <<'PY' + python3 - "$DISPLAY_BOUNDS" "$DRAG_POINTS" <<'PY' import sys left, top, right, bottom = map(int, sys.argv[1].replace(" ", "").split(",")) @@ -381,21 +370,50 @@ min_x, max_x = sorted((sx, ex)) min_y, max_y = sorted((sy, ey)) selection_width = max_x - min_x selection_height = max_y - min_y -sample_x = max_x + max(96, selection_width * 0.18) -if sample_x > right - 96: - sample_x = min_x - max(96, selection_width * 0.18) -sample_y = min_y + selection_height * 0.45 -sample_x = min(max(sample_x, left + 32), right - 32) -sample_y = min(max(sample_y, top + 32), bottom - 32) +margin = 96 +expanded = ( + min_x - max(margin, selection_width * 0.20), + min_y - max(margin, selection_height * 0.20), + max_x + max(margin, selection_width * 0.20), + max_y + max(margin, selection_height * 0.20), +) +candidates = [ + (left + margin, top + margin), + (right - margin, top + margin), + (left + margin, bottom - margin), + (right - margin, bottom - margin), +] +sample_x, sample_y = max( + candidates, + key=lambda point: min( + abs(point[0] - min_x), + abs(point[0] - max_x), + abs(point[1] - min_y), + abs(point[1] - max_y), + ), +) +for candidate_x, candidate_y in candidates: + if not ( + expanded[0] <= candidate_x <= expanded[2] + and expanded[1] <= candidate_y <= expanded[3] + ): + sample_x, sample_y = candidate_x, candidate_y + break print(f"{sample_x:.0f},{sample_y:.0f}") PY )" + fi + open_capture_overlay + smoke_log "drag $drag_index overlay focused" + + if [[ "$should_probe_drag" == "1" ]]; then MASK_PROBE_OUTPUT="$mask_probe_path" \ MASK_PROBE_PHASE_PATH="$mask_probe_phase_path" \ MASK_PROBE_READY_PATH="$mask_probe_ready_path" \ MASK_PROBE_SCREENSHOT_PATH="$drag_screenshot_path" \ MASK_PROBE_RELEASED_SCREENSHOT_PATH="$screenshot_path" \ + MASK_PROBE_SCREENSHOT_DELAY_MS="$MASK_PROBE_SCREENSHOT_DELAY_MS" \ MASK_PROBE_POINT="$mask_probe_point" \ MASK_PROBE_DURATION_MS="$mask_probe_duration_ms" \ MASK_PROBE_RATE_HZ="${MASK_PROBE_RATE_HZ:-60}" \ @@ -417,30 +435,25 @@ PY smoke_log "drag $drag_index mask probe ready" fi - press_capture_hotkey - sleep "$OVERLAY_SETTLE_S" - live_hud_focus_rsnap_overlay - smoke_log "drag $drag_index overlay focused" - if [[ "$should_probe_drag" != "1" ]]; then + smoke_log "drag $drag_index cursor path=$DRAG_POINTS" PATH_MODE=drag-region \ PATH_DRIVER=event \ - PATH_POINTS="$PATH_POINTS" \ + PATH_POINTS="$DRAG_POINTS" \ PATH_DURATION_MS="$DRAG_DURATION_MS" \ PATH_RATE_HZ="$PATH_RATE_HZ" \ PATH_HOLD_BEFORE_RELEASE_MS="$DRAG_HOLD_BEFORE_RELEASE_MS" \ "$cursor_helper_bin" sleep "$POST_FREEZE_SETTLE_S" - live_hud_focus_rsnap_overlay - live_hud_press_escape >/dev/null 2>&1 || true - sleep "$POST_CLOSE_SETTLE_S" + close_capture_overlay smoke_log "drag $drag_index closed overlay" continue fi + smoke_log "drag $drag_index cursor path=$DRAG_POINTS" PATH_MODE=drag-region \ PATH_DRIVER=event \ - PATH_POINTS="$PATH_POINTS" \ + PATH_POINTS="$DRAG_POINTS" \ PATH_DURATION_MS="$DRAG_DURATION_MS" \ PATH_RATE_HZ="$PATH_RATE_HZ" \ PATH_HOLD_BEFORE_RELEASE_MS="$DRAG_HOLD_BEFORE_RELEASE_MS" \ @@ -504,23 +517,48 @@ PY fi smoke_log "drag $drag_index captured frozen screenshot" drag_screenshot_paths="${drag_screenshot_paths:+$drag_screenshot_paths:}$drag_screenshot_path" - live_hud_focus_rsnap_overlay - live_hud_press_escape >/dev/null 2>&1 || true - sleep "$POST_CLOSE_SETTLE_S" + mask_probe_paths="${mask_probe_paths:+$mask_probe_paths:}$mask_probe_path" + close_capture_overlay smoke_log "drag $drag_index closed overlay" done + + for ((click_index = 1; click_index <= REPEATED_CLICK_FREEZES; click_index++)); do + smoke_log "click freeze guard $click_index/$REPEATED_CLICK_FREEZES" + open_capture_overlay + smoke_log "click guard $click_index point=${CLICK_POINTS%%;*}" + PATH_MODE=click-point \ + PATH_DRIVER=event \ + PATH_POINTS="$CLICK_POINTS" \ + "$cursor_helper_bin" + sleep "$POST_FREEZE_SETTLE_S" + live_hud_focus_rsnap_overlay + if [[ "$VERIFY_CLICK_FROZEN_MOVE" == "1" ]]; then + PATH_MODE=drag-region \ + PATH_DRIVER=event \ + PATH_POINTS="$CLICK_FROZEN_MOVE_POINTS" \ + PATH_DURATION_MS=90 \ + PATH_RATE_HZ=120 \ + PATH_HOLD_BEFORE_RELEASE_MS=0 \ + "$cursor_helper_bin" + sleep "$POST_FREEZE_SETTLE_S" + live_hud_focus_rsnap_overlay + smoke_log "click guard $click_index attempted forbidden frozen move" + fi + close_capture_overlay + smoke_log "click guard $click_index closed overlay" + done stop_visual_background local expected_editability local expected_transform_commits local max_transform_commits expected_editability="$( - python3 - "$REPEATED_CLICK_FREEZES" "$REPEATED_DRAG_FREEZES" <<'PY' + python3 - "$REPEATED_DRAG_FREEZES" "$REPEATED_CLICK_FREEZES" <<'PY' import sys -click_count = int(sys.argv[1]) -drag_count = int(sys.argv[2]) -print(",".join((["false"] * click_count) + (["true"] * drag_count))) +drag_count = int(sys.argv[1]) +click_count = int(sys.argv[2]) +print(",".join((["true"] * drag_count) + (["false"] * click_count))) PY )" expected_transform_commits=0 @@ -554,9 +592,9 @@ PY EXPECTED_FREEZE_EDITABILITY="$expected_editability" \ VISUAL_DRAG_SCREENSHOT_PATH="$drag_screenshot_paths" \ VISUAL_DISPLAY_BOUNDS="$DISPLAY_BOUNDS" \ - VISUAL_DRAG_POINTS="$PATH_POINTS" \ + VISUAL_DRAG_POINTS="$DRAG_POINTS" \ VISUAL_SCREENSHOT_PATH="$summary_screenshot_path" \ - MASK_PROBE_PATH="$mask_probe_path" \ + MASK_PROBE_PATH="$mask_probe_paths" \ SMOKE_STARTED_EPOCH="$case_started_epoch" \ python3 "$SCRIPT_DIR/lib/native-visual-contract-summary.py" "$out_dir/all.log" }