From 8626f170398df01d28fbf4e9a5545f8155ec1f91 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 6 May 2026 14:41:07 +0800 Subject: [PATCH 1/2] {"schema":"maestro/commit/1","summary":"Disable scroll capture and restore native host quality","authority":"manual"} --- .../Sources/RsnapHostBridgeProbe/main.swift | 6 +- .../FrozenFrameAuthority.swift | 149 ++++++++++++++++- .../GlobalHotKeyCenter.swift | 131 +++++++++++++-- .../RsnapNativeHostKit/NativeHostApp.swift | 158 ++++++++++++++++-- packages/rsnap-capture-core/src/session.rs | 19 +-- .../src/host_live_sampling_macos.rs | 56 ++++++- scripts/build_and_run.sh | 1 + scripts/perf/local.sh | 6 +- scripts/perf/macos.sh | 5 +- scripts/smoke/macos.sh | 11 +- scripts/smoke/self-check-macos.sh | 2 - 11 files changed, 475 insertions(+), 69 deletions(-) diff --git a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift index 4c02fcf6..fa56804a 100644 --- a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift +++ b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift @@ -104,15 +104,15 @@ enum RsnapHostBridgeProbe { scene.statusMessage == nil, scene.toolbarItems.contains(where: { $0.kind == .pointer && $0.selected }), scene.toolbarItems.contains(where: { $0.kind == .ocr && $0.enabled }), - scene.toolbarItems.contains(where: { $0.kind == .scroll && $0.enabled }), + !scene.toolbarItems.contains(where: { $0.kind == .scroll }), scene.toolbarItems.contains(where: { $0.kind == .copy && $0.enabled }), scene.toolbarItems.contains(where: { $0.kind == .save && $0.enabled }) else { fatalError("unexpected frozen scene: \(scene)") } try session.send(event: .toolbarItemInvoked(.scroll)) - guard try session.takeNextRequest() == .startScrollCapture else { - fatalError("expected a start-scroll-capture host request") + guard try session.takeNextRequest() == nil else { + fatalError("scroll toolbar invocation should stay disabled") } try session.send( diff --git a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift index 69d7b289..7d4b744b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift @@ -140,11 +140,27 @@ final class FrozenFrameAuthority: @unchecked Sendable { lock.unlock() } - func fresh(maxAge: TimeInterval) -> SCShareableContent? { + func fresh( + maxAge: TimeInterval, + covering displayIDs: Set? = nil + ) -> SCShareableContent? { let now = ProcessInfo.processInfo.systemUptime lock.lock() let content = now - cachedAtUptime <= maxAge ? self.content : nil lock.unlock() + guard let content else { + return nil + } + guard !content.displays.isEmpty else { + return nil + } + guard let displayIDs else { + return content + } + let availableDisplayIDs = Set(content.displays.map(\.displayID)) + guard displayIDs.isSubset(of: availableDisplayIDs) else { + return nil + } return content } } @@ -184,6 +200,27 @@ final class FrozenFrameAuthority: @unchecked Sendable { ) return } + guard Self.shareableContentHasDisplays(content) else { + NativeHostTelemetry.frozenAuthorityWarning( + "frozen_authority.content_cache_refresh_invalid", + captureID: captureID, + source: source, + displayID: 0, + error: Self.shareableContentDisplayDetail( + content, + requiredDisplayIDs: [] + ) + ) + NativeHostTelemetry.frozenAuthorityContentLookupTiming( + captureID: captureID, + source: source, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), + success: false, + displayCount: content.displays.count, + windowCount: content.windows.count + ) + return + } Self.shareableContentCache.store(content) NativeHostTelemetry.frozenAuthorityContentLookupTiming( captureID: captureID, @@ -196,8 +233,13 @@ final class FrozenFrameAuthority: @unchecked Sendable { } } - private static func cachedShareableContent() -> SCShareableContent? { - shareableContentCache.fresh(maxAge: shareableContentCacheMaxAge) + private static func cachedShareableContent( + covering displayIDs: Set? = nil + ) -> SCShareableContent? { + shareableContentCache.fresh( + maxAge: shareableContentCacheMaxAge, + covering: displayIDs + ) } func hasFreshShareableContentCache() -> Bool { @@ -314,7 +356,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { retryUntilUptime: TimeInterval, requestID: UInt64 ) { - if let content = Self.cachedShareableContent() { + if let content = Self.cachedShareableContent(covering: targetIDs) { let preparedFilters = Self.contentFilters( for: targets, in: content, @@ -388,17 +430,51 @@ final class FrozenFrameAuthority: @unchecked Sendable { return } + let contentCoversTargets = Self.shareableContent(content, covers: targetIDs) NativeHostTelemetry.frozenAuthorityContentLookupTiming( captureID: captureID, source: source, totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), - success: true, + success: contentCoversTargets, displayCount: content.displays.count, windowCount: content.windows.count ) guard self.isCurrentSetupRequest(requestID, targetIDs: targetIDs) else { return } + guard contentCoversTargets else { + NativeHostTelemetry.frozenAuthorityWarning( + "frozen_authority.content_lookup_invalid", + captureID: captureID, + source: source, + displayID: 0, + error: Self.shareableContentDisplayDetail( + content, + requiredDisplayIDs: targetIDs + ) + ) + if ProcessInfo.processInfo.systemUptime < retryUntilUptime { + DispatchQueue.global(qos: .userInteractive).asyncAfter( + deadline: .now() + Self.selfCaptureFilterRetryInterval + ) { [weak self] in + self?.rebuildStreamsFromShareableContent( + targets: targets, + targetIDs: targetIDs, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + retryUntilUptime: retryUntilUptime, + requestID: requestID + ) + } + return + } + self.finishSetup(targetIDs: targetIDs) + return + } + Self.shareableContentCache.store(content) let preparedFilters = Self.contentFilters( for: targets, in: content, @@ -476,7 +552,8 @@ final class FrozenFrameAuthority: @unchecked Sendable { source: String, startedAtUptime: TimeInterval ) { - if let content = Self.cachedShareableContent() { + let targetIDs = Set(targets.map(\.displayID)) + if let content = Self.cachedShareableContent(covering: targetIDs) { NativeHostTelemetry.frozenAuthorityContentLookupTiming( captureID: captureID, source: source, @@ -500,6 +577,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { ) return } + let retryUntilUptime = startedAtUptime + Self.selfCaptureFilterRetryWindow SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: false) { [weak self] content, error in guard let self else { @@ -527,14 +605,46 @@ final class FrozenFrameAuthority: @unchecked Sendable { self.finishSetup(generation: requestGeneration) return } + let contentCoversTargets = Self.shareableContent(content, covers: targetIDs) NativeHostTelemetry.frozenAuthorityContentLookupTiming( captureID: captureID, source: source, totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), - success: true, + success: contentCoversTargets, displayCount: content.displays.count, windowCount: content.windows.count ) + guard contentCoversTargets else { + NativeHostTelemetry.frozenAuthorityWarning( + "frozen_authority.content_lookup_invalid", + captureID: captureID, + source: source, + displayID: 0, + error: Self.shareableContentDisplayDetail( + content, + requiredDisplayIDs: targetIDs + ) + ) + if ProcessInfo.processInfo.systemUptime < retryUntilUptime { + DispatchQueue.global(qos: .userInteractive).asyncAfter( + deadline: .now() + Self.selfCaptureFilterRetryInterval + ) { [weak self] in + self?.configureStreamsFromShareableContent( + targets: targets, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, + generation: requestGeneration, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime + ) + } + return + } + self.finishSetup(generation: requestGeneration) + return + } + Self.shareableContentCache.store(content) let preparedFilters = Self.contentFilters( for: targets, in: content, @@ -1130,6 +1240,31 @@ final class FrozenFrameAuthority: @unchecked Sendable { return snapshot } + private static func shareableContentHasDisplays(_ content: SCShareableContent) -> Bool { + !content.displays.isEmpty + } + + private static func shareableContent( + _ content: SCShareableContent, + covers displayIDs: Set + ) -> Bool { + guard shareableContentHasDisplays(content) else { + return false + } + let availableDisplayIDs = Set(content.displays.map(\.displayID)) + return displayIDs.isSubset(of: availableDisplayIDs) + } + + private static func shareableContentDisplayDetail( + _ content: SCShareableContent, + requiredDisplayIDs: Set + ) -> String { + let required = requiredDisplayIDs.sorted().map { String($0) }.joined(separator: ",") + let available = content.displays.map(\.displayID).sorted().map { String($0) }.joined( + separator: ",") + return "requiredDisplayIDs=\(required) availableDisplayIDs=\(available)" + } + private static func streamConfiguration(for target: DisplayTarget) -> SCStreamConfiguration { let configuration = SCStreamConfiguration() configuration.width = target.widthPixels diff --git a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift index 1dcd099b..a32b875b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift @@ -9,9 +9,10 @@ final class GlobalHotKeyCenter { case capture = 1 case cancel = 2 case loupe = 3 + case save = 4 } - private struct HotKeyDefinition { + private struct HotKeyDefinition: Equatable { let keyCode: UInt32 let modifiers: UInt32 } @@ -21,10 +22,15 @@ final class GlobalHotKeyCenter { var onCaptureRequested: (() -> Void)? var onCancelRequested: (() -> Void)? var onToggleLoupeRequested: (() -> Void)? + var onCopyRequested: (() -> Void)? + var onAutoCenterRequested: (() -> Void)? + var onSaveRequested: (() -> Void)? private var handlerRef: EventHandlerRef? private var hotKeyRefs: [Binding: EventHotKeyRef?] = [:] - private var registeredBindings: Set = [] + private var registeredDefinitions: [Binding: HotKeyDefinition] = [:] + private var plainFrozenLocalMonitor: Any? + private var plainFrozenGlobalMonitor: Any? init() { var eventType = EventTypeSpec( @@ -48,28 +54,69 @@ final class GlobalHotKeyCenter { ) } - func updateBindings(captureHotKey: String, sceneMode: SceneKind) { + func invalidate() { + removePlainFrozenShortcutMonitors() + for binding in Binding.allCases { + unregister(binding) + } + if let handlerRef { + RemoveEventHandler(handlerRef) + self.handlerRef = nil + } + } + + func updateBindings( + captureHotKey: String, + sceneMode: SceneKind, + plainFrozenShortcutsEnabled: Bool + ) -> Bool { + var allRequestedBindingsRegistered = true let captureDefinition = Self.parseCaptureHotKey(captureHotKey) ?? Self.defaultCaptureHotKey - register(.capture, definition: captureDefinition) + allRequestedBindingsRegistered = + register(.capture, definition: captureDefinition) && allRequestedBindingsRegistered let wantsCancel = sceneMode != .hidden let wantsLoupe = sceneMode == .live + let wantsFrozen = sceneMode == .frozen if wantsCancel { - register(.cancel, definition: HotKeyDefinition(keyCode: 53, modifiers: 0)) + allRequestedBindingsRegistered = + register(.cancel, definition: HotKeyDefinition(keyCode: 53, modifiers: 0)) + && allRequestedBindingsRegistered } else { unregister(.cancel) } if wantsLoupe { - register(.loupe, definition: HotKeyDefinition(keyCode: 48, modifiers: 0)) + allRequestedBindingsRegistered = + register(.loupe, definition: HotKeyDefinition(keyCode: 48, modifiers: 0)) + && allRequestedBindingsRegistered } else { unregister(.loupe) } + + if wantsFrozen && plainFrozenShortcutsEnabled { + installPlainFrozenShortcutMonitors() + } else { + removePlainFrozenShortcutMonitors() + } + + if wantsFrozen { + allRequestedBindingsRegistered = + register(.save, definition: HotKeyDefinition(keyCode: 1, modifiers: UInt32(cmdKey))) + && allRequestedBindingsRegistered + } else { + unregister(.save) + } + + return allRequestedBindingsRegistered } - private func register(_ binding: Binding, definition: HotKeyDefinition) { - if registeredBindings.contains(binding) { + private func register(_ binding: Binding, definition: HotKeyDefinition) -> Bool { + if registeredDefinitions[binding] == definition { + return true + } + if registeredDefinitions[binding] != nil { unregister(binding) } @@ -89,23 +136,27 @@ final class GlobalHotKeyCenter { detail: "binding=\(binding.rawValue),keyCode=\(definition.keyCode),modifiers=\(definition.modifiers),status=\(status)" ) - return + return false } hotKeyRefs[binding] = hotKeyRef - registeredBindings.insert(binding) + registeredDefinitions[binding] = definition NativeHostTelemetry.lifecycleEvent( "native_host.hotkey_registered", detail: "binding=\(binding.rawValue),keyCode=\(definition.keyCode),modifiers=\(definition.modifiers)" ) + return true } private func unregister(_ binding: Binding) { + guard registeredDefinitions[binding] != nil || hotKeyRefs[binding] != nil else { + return + } if let hotKeyRef = hotKeyRefs[binding] { UnregisterEventHotKey(hotKeyRef) } hotKeyRefs[binding] = nil - registeredBindings.remove(binding) + registeredDefinitions.removeValue(forKey: binding) } private func handleHotKey(_ eventRef: EventRef) -> OSStatus { @@ -132,10 +183,68 @@ final class GlobalHotKeyCenter { onCancelRequested?() case .loupe: onToggleLoupeRequested?() + case .save: + onSaveRequested?() } return noErr } + private func installPlainFrozenShortcutMonitors() { + guard plainFrozenLocalMonitor == nil, plainFrozenGlobalMonitor == nil else { + return + } + plainFrozenLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + self?.handlePlainFrozenShortcut(event) == true ? nil : event + } + plainFrozenGlobalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { + [weak self] event in + DispatchQueue.main.async { + _ = self?.handlePlainFrozenShortcut(event) + } + } + } + + private func removePlainFrozenShortcutMonitors() { + if let monitor = plainFrozenLocalMonitor { + NSEvent.removeMonitor(monitor) + plainFrozenLocalMonitor = nil + } + if let monitor = plainFrozenGlobalMonitor { + NSEvent.removeMonitor(monitor) + plainFrozenGlobalMonitor = nil + } + } + + private func handlePlainFrozenShortcut(_ event: NSEvent) -> Bool { + guard !event.isARepeat else { + return false + } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard !flags.contains(.command), !flags.contains(.control), !flags.contains(.option), + !flags.contains(.shift) + else { + return false + } + switch event.keyCode { + case 49: + NativeHostTelemetry.lifecycleEvent( + "native_host.plain_frozen_hotkey", + detail: "keyCode=49,action=copy" + ) + onCopyRequested?() + case 8: + NativeHostTelemetry.lifecycleEvent( + "native_host.plain_frozen_hotkey", + detail: "keyCode=8,action=auto_center" + ) + onAutoCenterRequested?() + default: + return false + } + return true + } + private static let defaultCaptureHotKey = HotKeyDefinition( keyCode: 7, modifiers: UInt32(optionKey) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 0674e1c0..1a96a9b0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -306,6 +306,13 @@ private func frozenMosaicByte(_ value: CGFloat) -> UInt8 { public final class NativeHostApplicationController: NSObject, NSApplicationDelegate { private let settingsStore = NativeHostSettingsStore() private let globalHotKeys = GlobalHotKeyCenter() + private struct HotKeyBindingState: Equatable { + let captureHotKey: String + let sceneMode: SceneKind + let plainFrozenShortcutsEnabled: Bool + } + + private var appliedHotKeyBindingState: HotKeyBindingState? private var lifecycleActivity: NSObjectProtocol? private var selfCaptureRegistrationWindow: NSWindow? private var didBootstrap = false @@ -371,6 +378,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } public func applicationWillTerminate(_ notification: Notification) { + globalHotKeys.invalidate() sessionController.releaseScreenCaptureStreams(immediate: true) } @@ -541,6 +549,15 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg globalHotKeys.onToggleLoupeRequested = { [weak self] in self?.sessionController.toggleLoupe() } + globalHotKeys.onCopyRequested = { [weak self] in + self?.sessionController.copySelection() + } + globalHotKeys.onAutoCenterRequested = { [weak self] in + self?.sessionController.performFrozenAutoCenter() + } + globalHotKeys.onSaveRequested = { [weak self] in + self?.sessionController.saveSelection() + } } fileprivate func refreshStatusMenuState() { @@ -559,10 +576,20 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } private func refreshHotKeyBindings(for mode: SceneKind) { - globalHotKeys.updateBindings( + let bindingState = HotKeyBindingState( captureHotKey: settingsStore.settings.captureHotkey, - sceneMode: mode + sceneMode: mode, + plainFrozenShortcutsEnabled: sessionController.plainFrozenShortcutHotkeysEnabled + ) + guard bindingState != appliedHotKeyBindingState else { + return + } + let didApplyBindings = globalHotKeys.updateBindings( + captureHotKey: bindingState.captureHotKey, + sceneMode: bindingState.sceneMode, + plainFrozenShortcutsEnabled: bindingState.plainFrozenShortcutsEnabled ) + appliedHotKeyBindingState = didApplyBindings ? bindingState : nil } @objc @@ -634,6 +661,7 @@ final class CaptureSessionController: NSObject { private static let autoCenterMaxIterations = 6 private static let displayFirstFrameWait: TimeInterval = 0.025 private static let coldSelfCaptureRecoveryWait: TimeInterval = 3.5 + private static let scrollCaptureEnabled = false private static let scrollCaptureForwardingPassthrough: TimeInterval = 0.055 private static let scrollCaptureSampleDelay: TimeInterval = 0.04 private static let liveFrameStreamReleaseGrace: TimeInterval = 0.75 @@ -1215,6 +1243,9 @@ final class CaptureSessionController: NSObject { } func startScrollCapture() { + guard Self.scrollCaptureEnabled else { + return + } let _ = chromeState.frozenOverlay.commitTextEdit( style: chromeState.annotationStyle.textStyle) sendFrozenAction(.toolbarItemInvoked(.scroll)) @@ -1746,6 +1777,11 @@ final class CaptureSessionController: NSObject { editable: selectionEditable ) case .startScrollCapture: + guard Self.scrollCaptureEnabled else { + try setHostStatusMessage("Scroll capture is temporarily disabled.") + refreshOverlay() + return + } try beginNativeScrollCapture() case .copyCapture: try performCopy() @@ -2050,8 +2086,10 @@ final class CaptureSessionController: NSObject { ToolbarItem(kind: .undo, enabled: false, selected: false), ToolbarItem(kind: .redo, enabled: false, selected: false), ToolbarItem(kind: .autoCenter, enabled: true, selected: false), - ToolbarItem(kind: .scroll, enabled: scrollEnabled, selected: false), ] + if Self.scrollCaptureEnabled { + items.append(ToolbarItem(kind: .scroll, enabled: scrollEnabled, selected: false)) + } if allowTextInput { items.append(ToolbarItem(kind: .ocr, enabled: true, selected: false)) } @@ -2061,12 +2099,22 @@ final class CaptureSessionController: NSObject { } var scrollCaptureToolbarEnabled: Bool { - scene.mode == .frozen + Self.scrollCaptureEnabled + && scene.mode == .frozen && scrollCaptureState == nil && currentFrozenSelection() != nil } + var plainFrozenShortcutHotkeysEnabled: Bool { + scene.mode == .frozen + && scrollCaptureState == nil + && chromeState.frozenOverlay.activeTextEdit == nil + } + func handleScrollCaptureWheel(_ event: NSEvent, at point: CGPoint) -> Bool { + guard Self.scrollCaptureEnabled else { + return false + } guard var state = scrollCaptureState else { return false } @@ -2144,6 +2192,11 @@ final class CaptureSessionController: NSObject { } private func beginNativeScrollCapture() throws { + guard Self.scrollCaptureEnabled else { + try setHostStatusMessage("Scroll capture is temporarily disabled.") + refreshOverlay() + return + } guard scrollCaptureState == nil else { try setHostStatusMessage("Scroll capture is already active.") refreshOverlay() @@ -2525,6 +2578,9 @@ final class CaptureSessionController: NSObject { } private func activeScrollCaptureExportImage() throws -> CGImage? { + guard Self.scrollCaptureEnabled else { + return nil + } guard let state = scrollCaptureState else { return nil } @@ -2992,6 +3048,7 @@ final class CaptureSessionController: NSObject { chrome: chromeState, settings: settingsStore.settings ) + sceneDidChange?(scene) } private func tearDownCapture() { @@ -4280,6 +4337,10 @@ final class CaptureHostView: NSView { let font: NSFont let lineHeight: CGFloat let commaWidth: CGFloat + let xPrefixWidth: CGFloat + let yPrefixWidth: CGFloat + let digitWidth: CGFloat + let minusWidth: CGFloat let keycapTextSize: CGSize let keycapFrameSize: CGSize let hexSlotWidth: CGFloat @@ -4294,6 +4355,10 @@ final class CaptureHostView: NSView { font: font, lineHeight: ceil("x=0".size(using: font).height), commaWidth: ",".size(using: font).width, + xPrefixWidth: "x=".size(using: font).width, + yPrefixWidth: "y=".size(using: font).width, + digitWidth: "0".size(using: font).width, + minusWidth: "-".size(using: font).width, keycapTextSize: keycapTextSize, keycapFrameSize: CGSize( width: keycapTextSize.width + 12, height: keycapTextSize.height + 4), @@ -4335,6 +4400,7 @@ final class CaptureHostView: NSView { private var hoveredAnnotationStyleAction: FrozenAnnotationStyleAction? private var annotationStyleWheelLastStepTimestamp: TimeInterval? private var lastCursorPresentation: CursorPresentation? + private var lastAppliedCursorPresentation: CursorPresentation? private var queuedPointerEvent: QueuedPointerEvent? private var queuedPointerWorkItem: DispatchWorkItem? private var lastHoverPointerDispatchUptime: TimeInterval = 0 @@ -4387,7 +4453,7 @@ final class CaptureHostView: NSView { else { return super.hitTest(point) } - return nil + return self } override init(frame frameRect: NSRect) { @@ -4738,7 +4804,7 @@ final class CaptureHostView: NSView { if scene.mode == .frozen { refreshHoveredToolbarAction(for: event.locationInWindow) } - cursor(for: currentCursorPresentation()).set() + applyVisibleCursorIfNeeded(currentCursorPresentation()) } override func mouseMoved(with event: NSEvent) { @@ -4893,9 +4959,6 @@ final class CaptureHostView: NSView { case "c": controller?.performFrozenAutoCenter() return - case "s": - controller?.startScrollCapture() - return case "r": guard toolbarItem(.ocr)?.enabled == true else { return @@ -4915,6 +4978,7 @@ final class CaptureHostView: NSView { return !flags.contains(.command) && !flags.contains(.control) && !flags.contains(.option) + && !flags.contains(.shift) } private static let annotationStyleWheelDeadZone: CGFloat = 0.05 @@ -5602,10 +5666,11 @@ final class CaptureHostView: NSView { } recordLivePointerEventGap() let pointerChanged = setLivePointerPreview(to: globalPoint) - if pointerChanged || rendersImmediately { + let hoverTargetChanged = refreshLiveHighlightedWindowPreviewForFastPath(at: globalPoint) + if pointerChanged || rendersImmediately || hoverTargetChanged { updateLivePreviewSampleDemand() moveLiveChromeLayers() - if rendersFullPreview { + if rendersFullPreview || hoverTargetChanged { liveRenderer.renderNow() } else { liveRenderer.renderLiveChromeNow() @@ -6412,7 +6477,9 @@ final class CaptureHostView: NSView { } private func frozenToolbarScrimExclusionPath(for selection: CGRect) -> CGPath? { - guard settings.usesLiquidHudGlass, frozenToolbarLiquidGlassVisible, + guard settings.usesLiquidHudGlass, + frozenToolbarLiquidGlassVisible, + frozenToolbarLiquidGlassContentDrawn, let toolbarFrame = toolbarLayout(for: selection)?.frame else { return nil @@ -6443,7 +6510,7 @@ final class CaptureHostView: NSView { } private func visibleToolbarItems() -> [ToolbarItem] { - scene.toolbarItems.map { item in + scene.toolbarItems.compactMap { item in var item = item switch item.kind { case .pen, .arrow, .mosaic, .spotlight, .text: @@ -6457,6 +6524,9 @@ final class CaptureHostView: NSView { scene.frozenSelection != nil && !chrome.frozenOverlay.keepsFrozenSelectionFixed case .scroll: + guard controller?.scrollCaptureToolbarEnabled == true else { + return nil + } item.enabled = controller?.scrollCaptureToolbarEnabled ?? false default: break @@ -6634,8 +6704,16 @@ final class CaptureHostView: NSView { lastCursorPresentation = cursorPresentation window?.invalidateCursorRects(for: self) if scene.mode == .frozen { - cursor(for: cursorPresentation).set() + applyVisibleCursorIfNeeded(cursorPresentation) + } + } + + private func applyVisibleCursorIfNeeded(_ cursorPresentation: CursorPresentation) { + guard cursorPresentation != lastAppliedCursorPresentation else { + return } + lastAppliedCursorPresentation = cursorPresentation + cursor(for: cursorPresentation).set() } private func currentHudPlacement() -> LiveFloatingPlacement? { @@ -6888,6 +6966,35 @@ final class CaptureHostView: NSView { liveHighlightedWindowPreview = controller?.previewHighlightedWindow(at: globalPoint) } + private func refreshLiveHighlightedWindowPreviewForFastPath(at globalPoint: CGPoint) -> Bool { + guard liveDragStartGlobal == nil, !liveHoverChromeSuppressed else { + return false + } + let previousPreview = liveHighlightedWindowPreview + refreshLiveHighlightedWindowPreview(at: globalPoint) + return !Self.windowSnapshotsEquivalent(previousPreview, liveHighlightedWindowPreview) + } + + private static func windowSnapshotsEquivalent(_ lhs: WindowSnapshot?, _ rhs: WindowSnapshot?) + -> Bool + { + switch (lhs, rhs) { + case (nil, nil): + return true + case (let lhs?, let rhs?): + return lhs.windowID == rhs.windowID && windowFramesEquivalent(lhs.frame, rhs.frame) + default: + return false + } + } + + private static func windowFramesEquivalent(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + abs(lhs.minX - rhs.minX) <= 0.5 + && abs(lhs.minY - rhs.minY) <= 0.5 + && abs(lhs.width - rhs.width) <= 0.5 + && abs(lhs.height - rhs.height) <= 0.5 + } + private func updateLiveChromeBackdrops() { let frames = currentLiveChromeLayerFrames() updateLiveChromeBackdrops(hudFrame: frames.hud, loupeFrame: frames.loupe) @@ -7246,11 +7353,30 @@ final class CaptureHostView: NSView { return LivePositionDisplay( xValueText: xValueText, yValueText: yValueText, - xSlotWidth: "x=\(xValueText)".size(using: metrics.font).width, - ySlotWidth: "y=\(yValueText)".size(using: metrics.font).width + xSlotWidth: Self.coordinateSlotWidth( + prefixWidth: metrics.xPrefixWidth, + valueText: xValueText, + metrics: metrics + ), + ySlotWidth: Self.coordinateSlotWidth( + prefixWidth: metrics.yPrefixWidth, + valueText: yValueText, + metrics: metrics + ) ) } + private static func coordinateSlotWidth( + prefixWidth: CGFloat, + valueText: String, + metrics: HudLayoutMetrics + ) -> CGFloat { + prefixWidth + + valueText.reduce(CGFloat(0)) { width, character in + width + (character == "-" ? metrics.minusWidth : metrics.digitWidth) + } + } + private func currentLiveColorDisplay(for sample: RGBSample?) -> LiveColorDisplay { let hexText = sample.map { String(format: "#%02X%02X%02X", $0.r, $0.g, $0.b) } diff --git a/packages/rsnap-capture-core/src/session.rs b/packages/rsnap-capture-core/src/session.rs index 52279327..007397f5 100644 --- a/packages/rsnap-capture-core/src/session.rs +++ b/packages/rsnap-capture-core/src/session.rs @@ -217,9 +217,7 @@ impl CaptureSessionCore { self.scene.status_message = None; }, ToolbarItemKind::Undo | ToolbarItemKind::Redo | ToolbarItemKind::AutoCenter => {}, - ToolbarItemKind::Scroll => { - self.pending_requests.push_back(HostRequest::StartScrollCapture); - }, + ToolbarItemKind::Scroll => {}, ToolbarItemKind::Ocr => { if self.config.allow_text_input { self.pending_requests @@ -252,7 +250,6 @@ impl CaptureSessionCore { self.toolbar_item(ToolbarItemKind::Undo, false), self.toolbar_item(ToolbarItemKind::Redo, false), self.toolbar_item(ToolbarItemKind::AutoCenter, true), - self.toolbar_item(ToolbarItemKind::Scroll, self.frozen_selection_editable), ]; if self.config.allow_text_input { @@ -564,13 +561,13 @@ mod tests { assert_eq!(session.scene_model().mode, CaptureMode::Frozen); assert_eq!(session.scene_model().cursor_intent, CursorIntent::Grab); - assert_eq!(session.scene_model().toolbar_items.len(), 13); + assert_eq!(session.scene_model().toolbar_items.len(), 12); assert!( session .scene_model() .toolbar_items .iter() - .any(|item| item.kind == ToolbarItemKind::Scroll && item.enabled) + .all(|item| item.kind != ToolbarItemKind::Scroll) ); } @@ -880,18 +877,18 @@ mod tests { } #[test] - fn scroll_toolbar_invocation_requests_native_scroll_capture_for_drag_region() { + fn scroll_toolbar_invocation_is_disabled_for_drag_region() { let mut session = CaptureSessionCore::with_config(SessionConfig::default()); enter_frozen_with_drag_selection(&mut session, GlobalRect::new(10, 20, 100, 50)); session.handle_host_event(HostEvent::ToolbarItemInvoked { item: ToolbarItemKind::Scroll }); - assert_eq!(session.pop_host_request(), Some(HostRequest::StartScrollCapture)); + assert_eq!(session.pop_host_request(), None); } #[test] - fn scroll_toolbar_requests_native_host_feedback_for_non_editable_frozen_selection() { + fn scroll_toolbar_is_absent_for_non_editable_frozen_selection() { let selection = GlobalRect::new(10, 20, 100, 50); let mut session = CaptureSessionCore::with_config(SessionConfig::default()); @@ -907,9 +904,9 @@ mod tests { .scene_model() .toolbar_items .iter() - .any(|item| item.kind == ToolbarItemKind::Scroll && !item.enabled) + .all(|item| item.kind != ToolbarItemKind::Scroll) ); - assert_eq!(session.pop_host_request(), Some(HostRequest::StartScrollCapture)); + assert_eq!(session.pop_host_request(), None); } #[test] diff --git a/packages/rsnap-overlay/src/host_live_sampling_macos.rs b/packages/rsnap-overlay/src/host_live_sampling_macos.rs index e5c48a82..97af0fcb 100644 --- a/packages/rsnap-overlay/src/host_live_sampling_macos.rs +++ b/packages/rsnap-overlay/src/host_live_sampling_macos.rs @@ -1,7 +1,7 @@ //! Public macOS live-frame sampling bridge used by the native host FFI layer. use crate::live_frame_stream_macos::{CursorSampleRequest, MacLiveFrameStream}; -use crate::state::{GlobalPoint, LiveCursorSample, MonitorRect}; +use crate::state::{GlobalPoint, LiveCursorSample, MonitorRect, RectPoints}; /// Live cursor sample plus the ScreenCaptureKit frame metadata that produced it. pub struct HostLiveCursorSample { @@ -118,9 +118,7 @@ impl HostMacLiveSampler { width: u32, height: u32, ) -> Option { - let width = i32::try_from(width).ok()?; - let height = i32::try_from(height).ok()?; - let rect = monitor.clip_global_rect(origin.x, origin.y, width, height)?; + let rect = clipped_region_rect(monitor, origin, width, height)?; let rect_px = monitor.local_rect_to_pixels(rect); let image = self.stream.latest_rgba_region(monitor, rect_px)?; @@ -165,3 +163,53 @@ pub struct HostRgbaRegion { /// Packed RGBA8 pixels in row-major order. pub rgba: Vec, } + +fn clipped_region_rect( + monitor: MonitorRect, + origin: GlobalPoint, + width: u32, + height: u32, +) -> Option { + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let right = origin.x.checked_add(width)?; + let bottom = origin.y.checked_add(height)?; + + monitor.clip_global_rect(origin.x, origin.y, right, bottom) +} + +#[cfg(test)] +mod tests { + use crate::state::{GlobalPoint, MonitorRect, RectPoints}; + + #[test] + fn live_region_rect_treats_size_as_extent_not_bottom_right() { + let monitor = MonitorRect { + id: 1, + origin: GlobalPoint::new(0, 0), + width: 1_440, + height: 900, + scale_factor_x1000: 2_000, + }; + let rect = super::clipped_region_rect(monitor, GlobalPoint::new(280, 120), 724, 632) + .expect("region should be inside monitor"); + + assert_eq!(rect, RectPoints::new(280, 120, 724, 632)); + assert_eq!(monitor.local_rect_to_pixels(rect), RectPoints::new(560, 240, 1_448, 1_264)); + } + + #[test] + fn live_region_rect_clips_against_nonzero_monitor_origin() { + let monitor = MonitorRect { + id: 2, + origin: GlobalPoint::new(-100, -50), + width: 400, + height: 300, + scale_factor_x1000: 1_000, + }; + let rect = super::clipped_region_rect(monitor, GlobalPoint::new(-150, -60), 120, 100) + .expect("partially visible region should clip"); + + assert_eq!(rect, RectPoints::new(0, 0, 70, 90)); + } +} diff --git a/scripts/build_and_run.sh b/scripts/build_and_run.sh index cfc7030d..32087bd5 100755 --- a/scripts/build_and_run.sh +++ b/scripts/build_and_run.sh @@ -330,6 +330,7 @@ targets = [ "native/macos-host", "packages/rsnap-capture-core", "packages/rsnap-host-ffi", + "packages/rsnap-overlay", "scripts/build_and_run.sh", ] skip_dirs = {".git", ".worktrees", "target", ".build"} diff --git a/scripts/perf/local.sh b/scripts/perf/local.sh index 2a91c0a9..c7ed32da 100755 --- a/scripts/perf/local.sh +++ b/scripts/perf/local.sh @@ -8,12 +8,12 @@ case "${1:-}" in cat <<'EOF' Usage: local.sh -Runs the local deterministic performance sweep: - 1. scroll-capture benchmark +Runs the local deterministic performance sweep. +No local deterministic benchmarks are enabled while scroll capture is disabled. EOF exit 0 ;; esac cd "$ROOT_DIR" -cargo bench -p rsnap-overlay --bench scroll_capture -- --sample-size 10 --warm-up-time 0.1 --measurement-time 0.1 +echo "[perf] no local deterministic benchmarks are enabled while scroll capture is disabled." diff --git a/scripts/perf/macos.sh b/scripts/perf/macos.sh index 16f2759e..b1085a5e 100755 --- a/scripts/perf/macos.sh +++ b/scripts/perf/macos.sh @@ -9,9 +9,9 @@ case "${1:-}" in Usage: macos.sh Runs the full macOS performance sequence: - 1. local deterministic benchmarks + 1. local non-scroll performance checks 2. native-host HUD-follow smoke - 3. recorded live-trace replay + 3. native-host visual/behavior contract smoke EOF exit 0 ;; @@ -20,4 +20,3 @@ esac "$SCRIPT_DIR/local.sh" "$SCRIPT_DIR/../smoke/native-hud-follow-macos.sh" "$SCRIPT_DIR/../smoke/native-visual-contract-macos.sh" -"$SCRIPT_DIR/../smoke/replay-scroll-capture.sh" diff --git a/scripts/smoke/macos.sh b/scripts/smoke/macos.sh index ad4cf30e..1ad6091f 100755 --- a/scripts/smoke/macos.sh +++ b/scripts/smoke/macos.sh @@ -10,18 +10,11 @@ Usage: macos.sh Runs the macOS smoke sequence: 1. native-host visual/behavior contract smoke - 2. recorded live-trace replay in worker-pairwise mode, or replay self-check when no trace exists + 2. native-host HUD-follow responsiveness smoke EOF exit 0 ;; esac "$SCRIPT_DIR/native-visual-contract-macos.sh" - -TRACE_ROOT="${RSNAP_SCROLL_CAPTURE_TRACE_DIR:-$HOME/Library/Application Support/ink.hack.rsnap/scroll-capture-traces}" -if [[ -d "$TRACE_ROOT" ]] && find "$TRACE_ROOT" -mindepth 2 -maxdepth 2 -name manifest.json -print -quit | grep -q .; then - "$SCRIPT_DIR/replay-scroll-capture.sh" -else - echo "[smoke] no recorded scroll-capture trace found; running replay self-check" - "$SCRIPT_DIR/replay-scroll-capture-self-check.sh" -fi +"$SCRIPT_DIR/native-hud-follow-macos.sh" diff --git a/scripts/smoke/self-check-macos.sh b/scripts/smoke/self-check-macos.sh index c9f67160..377fcda6 100755 --- a/scripts/smoke/self-check-macos.sh +++ b/scripts/smoke/self-check-macos.sh @@ -10,11 +10,9 @@ Usage: self-check-macos.sh Runs the macOS smoke readiness sequence: 1. native HUD-follow smoke environment self-check - 2. deterministic replay self-check EOF exit 0 ;; esac "$SCRIPT_DIR/native-hud-follow-macos.sh" --self-check -"$SCRIPT_DIR/replay-scroll-capture-self-check.sh" From bfed83d2105ff4f1fc418342ebd28ab28dfd6e18 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 6 May 2026 15:24:25 +0800 Subject: [PATCH 2/2] {"schema":"maestro/commit/1","summary":"Harden native host validation surface","authority":"manual"} --- .../smoke-perf-validation-surface.md | 8 +-- docs/runbook/performance-validation.md | 22 +++--- .../HotKeyBindingCoordinator.swift | 69 +++++++++++++++++++ .../RsnapNativeHostKit/NativeHostApp.swift | 34 +++------ scripts/perf/self-check-macos.sh | 1 - .../smoke/lib/native-hud-follow-summary.py | 29 +++++++- scripts/smoke/native-hud-follow-macos.sh | 2 + 7 files changed, 120 insertions(+), 45 deletions(-) create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift diff --git a/docs/reference/smoke-perf-validation-surface.md b/docs/reference/smoke-perf-validation-surface.md index f014a4ac..daa54481 100644 --- a/docs/reference/smoke-perf-validation-surface.md +++ b/docs/reference/smoke-perf-validation-surface.md @@ -33,13 +33,13 @@ overlay runtime integration tests, and scroll-capture session semantics tests. | `scripts/smoke/replay-scroll-capture.sh` | Script entrypoint | deterministic replay | Runs the latest recorded trace through worker-pairwise replay. | | `scripts/smoke/replay-scroll-capture-self-check.sh` | Script entrypoint | deterministic replay | Runs the worker-pairwise replay self-check without a user trace. | | `scripts/smoke/analyze-scroll-capture-trace.sh` | Script entrypoint | deterministic replay | Emits summary-only replay analysis for semantic drift triage. | -| `scripts/smoke/native-hud-follow-macos.sh` | Script entrypoint | live macOS perf smoke | Dedicated HUD/loupe follow-cadence smoke for performance work, not part of the default macOS smoke aggregation. | +| `scripts/smoke/native-hud-follow-macos.sh` | Script entrypoint | live macOS perf smoke | HUD/loupe follow-cadence smoke for performance work, including delivered mouse-event count, sample refresh cadence, active-layer chrome cadence, and frame-tick cadence. | | `scripts/smoke/native-visual-contract-macos.sh` | Script entrypoint | live macOS smoke | Core native-host behavior contract: repeated real click freezes, repeated held drag freezes, in-drag and frozen screenshots, click/drag editability, border-leak, scrim, and handoff telemetry gates. | -| `scripts/smoke/self-check-macos.sh` | Script entrypoint | smoke readiness | Verifies macOS smoke tooling and replay self-check without the real GUI run. | -| `scripts/smoke/macos.sh` | Script entrypoint | smoke aggregation | Runs the core native visual contract, then recorded-trace replay when a local trace exists or replay self-check when it does not. | +| `scripts/smoke/self-check-macos.sh` | Script entrypoint | smoke readiness | Verifies native HUD-follow smoke tooling readiness without the real GUI run. | +| `scripts/smoke/macos.sh` | Script entrypoint | smoke aggregation | Runs the core native visual contract and HUD-follow responsiveness smoke. | | `scripts/perf/local.sh` | Script entrypoint | deterministic benches | Runs the committed Criterion smoke-sized benchmark sweep. | | `scripts/perf/self-check-macos.sh` | Script entrypoint | perf aggregation | Runs local deterministic benches plus macOS smoke readiness. | -| `scripts/perf/macos.sh` | Script entrypoint | perf aggregation | Runs local deterministic benches, the dedicated HUD-follow perf smoke, the core native visual contract, and recorded-trace replay. | +| `scripts/perf/macos.sh` | Script entrypoint | perf aggregation | Runs local deterministic benches, the HUD-follow perf smoke, and the core native visual contract. | | `packages/rsnap-overlay/src/overlay/replay_support.rs` tests | Deterministic replay / bench | replay harness | Trace round-trip, replay mode selection, and summary classification. | | `packages/rsnap-overlay/benches/scroll_capture.rs` | Deterministic replay / bench | hot-path perf | Stable fingerprint, overlap-match, and one-step session commit baselines. | | `packages/rsnap-overlay/src/overlay/tests/worker_tick_runtime.rs` | Overlay runtime integration | overlay runtime | Request issuance, retry timing, backoff, and fresh-input worker scheduling. | diff --git a/docs/runbook/performance-validation.md b/docs/runbook/performance-validation.md index 1b2ed354..4b47aa6e 100644 --- a/docs/runbook/performance-validation.md +++ b/docs/runbook/performance-validation.md @@ -56,20 +56,18 @@ worker-pairwise self-check path when no recorded user trace is available. - Use this for routine local comparisons and for regressions that do not require a real desktop session. - `scripts/perf/self-check-macos.sh` - - Runs `scripts/perf/local.sh`, then runs the native HUD-follow self-check plus - recorded-live-trace scroll-capture replay self-check. + - Runs `scripts/perf/local.sh`, then runs the native HUD-follow self-check through + `scripts/smoke/self-check-macos.sh`. - Use this to validate that the dedicated macOS environment, permissions, and smoke harness are ready without treating it as an end-to-end performance assertion. - `scripts/perf/macos.sh` - Runs `scripts/perf/local.sh`, the dedicated native-host HUD-follow perf smoke, the core native - visual contract smoke, plus recorded-live-trace scroll-capture replay. + visual contract smoke. - Use this only on a dedicated logged-in macOS desktop session with the expected Screen Recording and automation permissions. - `scripts/smoke/macos.sh` - Runs the core native visual contract smoke. - - Runs recorded-live-trace replay when a local `manifest.json` exists under the scroll-capture - trace directory; otherwise it runs `scripts/smoke/replay-scroll-capture-self-check.sh` so a - missing optional trace does not fail unrelated native-host validation. + - Runs the native HUD-follow responsiveness smoke. For the downward scroll-capture rebuild, the expected verification sequence is: @@ -109,8 +107,12 @@ Dedicated macOS smoke: - Requires a logged-in macOS desktop session. - Requires the expected Screen Recording and automation permissions for the smoke scripts. - Covers the native-host HUD-follow desktop path. The hard follow gate uses active pointer-movement - cadence (`live_chrome.active_layer_chrome_render_gap`) rather than startup, Tab-expand, or close - transition gaps. + cadence (`live_chrome.active_layer_chrome_render_gap`) and frame-tick cadence + (`live_chrome.frame_tick_gap`) rather than startup, Tab-expand, or close transition gaps. +- Requires the smoke harness to deliver enough mouse-movement input. For the default smooth event + path, `native-hud-follow-macos.sh` expects at least half of the requested + `(PATH_DURATION_MS / 1000) * PATH_RATE_HZ` event count; override with `MIN_MOUSE_EVENTS` only + when validating a different input driver or intentionally degraded environment. - Interpret cadence metrics by class: - display-bound visual presentation metrics are gated against `min(active display maximum refresh rate, 120 Hz)`, so a `60 Hz` monitor has a `16.67 ms` @@ -133,10 +135,6 @@ Dedicated macOS smoke: worker-pairwise overlay or session logic before attempting more desktop-session repro. If the command reports that no trace manifests were found, that is an operator/setup failure: record a fresh live trace first or rerun the example with `--trace `. -- `scripts/smoke/macos.sh` choosing replay self-check: - treat it as expected when the machine has no recorded scroll-capture trace. It is not evidence - for or against the latest user-recorded live trace; run `scripts/smoke/replay-scroll-capture.sh` - with a real trace when scroll-capture replay evidence is required. - `scripts/smoke/replay-scroll-capture-self-check.sh` failures: treat them as deterministic regressions in the replay harness itself, not as evidence about the latest user-recorded live trace. diff --git a/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift b/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift new file mode 100644 index 00000000..8eedfd37 --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/HotKeyBindingCoordinator.swift @@ -0,0 +1,69 @@ +import RsnapHostBridge + +@MainActor +final class HotKeyBindingCoordinator { + private struct BindingState: Equatable { + let captureHotKey: String + let sceneMode: SceneKind + let plainFrozenShortcutsEnabled: Bool + } + + var onCaptureRequested: (() -> Void)? { + get { hotKeys.onCaptureRequested } + set { hotKeys.onCaptureRequested = newValue } + } + + var onCancelRequested: (() -> Void)? { + get { hotKeys.onCancelRequested } + set { hotKeys.onCancelRequested = newValue } + } + + var onToggleLoupeRequested: (() -> Void)? { + get { hotKeys.onToggleLoupeRequested } + set { hotKeys.onToggleLoupeRequested = newValue } + } + + var onCopyRequested: (() -> Void)? { + get { hotKeys.onCopyRequested } + set { hotKeys.onCopyRequested = newValue } + } + + var onAutoCenterRequested: (() -> Void)? { + get { hotKeys.onAutoCenterRequested } + set { hotKeys.onAutoCenterRequested = newValue } + } + + var onSaveRequested: (() -> Void)? { + get { hotKeys.onSaveRequested } + set { hotKeys.onSaveRequested = newValue } + } + + private let hotKeys = GlobalHotKeyCenter() + private var appliedState: BindingState? + + func update( + captureHotKey: String, + sceneMode: SceneKind, + plainFrozenShortcutsEnabled: Bool + ) { + let state = BindingState( + captureHotKey: captureHotKey, + sceneMode: sceneMode, + plainFrozenShortcutsEnabled: plainFrozenShortcutsEnabled + ) + guard state != appliedState else { + return + } + let didApply = hotKeys.updateBindings( + captureHotKey: state.captureHotKey, + sceneMode: state.sceneMode, + plainFrozenShortcutsEnabled: state.plainFrozenShortcutsEnabled + ) + appliedState = didApply ? state : nil + } + + func invalidate() { + hotKeys.invalidate() + appliedState = nil + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 1a96a9b0..dbdf7f87 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -305,14 +305,7 @@ private func frozenMosaicByte(_ value: CGFloat) -> UInt8 { @MainActor public final class NativeHostApplicationController: NSObject, NSApplicationDelegate { private let settingsStore = NativeHostSettingsStore() - private let globalHotKeys = GlobalHotKeyCenter() - private struct HotKeyBindingState: Equatable { - let captureHotKey: String - let sceneMode: SceneKind - let plainFrozenShortcutsEnabled: Bool - } - - private var appliedHotKeyBindingState: HotKeyBindingState? + private let hotKeyCoordinator = HotKeyBindingCoordinator() private var lifecycleActivity: NSObjectProtocol? private var selfCaptureRegistrationWindow: NSWindow? private var didBootstrap = false @@ -378,7 +371,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } public func applicationWillTerminate(_ notification: Notification) { - globalHotKeys.invalidate() + hotKeyCoordinator.invalidate() sessionController.releaseScreenCaptureStreams(immediate: true) } @@ -540,22 +533,22 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } private func configureGlobalHotKeys() { - globalHotKeys.onCaptureRequested = { [weak self] in + hotKeyCoordinator.onCaptureRequested = { [weak self] in self?.startCapture(nil) } - globalHotKeys.onCancelRequested = { [weak self] in + hotKeyCoordinator.onCancelRequested = { [weak self] in self?.cancelCapture(nil) } - globalHotKeys.onToggleLoupeRequested = { [weak self] in + hotKeyCoordinator.onToggleLoupeRequested = { [weak self] in self?.sessionController.toggleLoupe() } - globalHotKeys.onCopyRequested = { [weak self] in + hotKeyCoordinator.onCopyRequested = { [weak self] in self?.sessionController.copySelection() } - globalHotKeys.onAutoCenterRequested = { [weak self] in + hotKeyCoordinator.onAutoCenterRequested = { [weak self] in self?.sessionController.performFrozenAutoCenter() } - globalHotKeys.onSaveRequested = { [weak self] in + hotKeyCoordinator.onSaveRequested = { [weak self] in self?.sessionController.saveSelection() } } @@ -576,20 +569,11 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg } private func refreshHotKeyBindings(for mode: SceneKind) { - let bindingState = HotKeyBindingState( + hotKeyCoordinator.update( captureHotKey: settingsStore.settings.captureHotkey, sceneMode: mode, plainFrozenShortcutsEnabled: sessionController.plainFrozenShortcutHotkeysEnabled ) - guard bindingState != appliedHotKeyBindingState else { - return - } - let didApplyBindings = globalHotKeys.updateBindings( - captureHotKey: bindingState.captureHotKey, - sceneMode: bindingState.sceneMode, - plainFrozenShortcutsEnabled: bindingState.plainFrozenShortcutsEnabled - ) - appliedHotKeyBindingState = didApplyBindings ? bindingState : nil } @objc diff --git a/scripts/perf/self-check-macos.sh b/scripts/perf/self-check-macos.sh index 0c5eed63..a454a732 100755 --- a/scripts/perf/self-check-macos.sh +++ b/scripts/perf/self-check-macos.sh @@ -11,7 +11,6 @@ Usage: self-check-macos.sh Runs the macOS performance readiness sequence: 1. local deterministic benchmarks 2. macOS smoke self-check - 3. deterministic replay self-check EOF exit 0 ;; diff --git a/scripts/smoke/lib/native-hud-follow-summary.py b/scripts/smoke/lib/native-hud-follow-summary.py index cc274249..3a8754dc 100644 --- a/scripts/smoke/lib/native-hud-follow-summary.py +++ b/scripts/smoke/lib/native-hud-follow-summary.py @@ -46,6 +46,23 @@ def int_field(summary: dict, key: str) -> int: except ValueError: return 0 + +def expected_min_mouse_events() -> int: + explicit = os.environ.get("MIN_MOUSE_EVENTS") + if explicit not in (None, ""): + return max(0, int(explicit)) + if os.environ.get("PATH_DRIVER", "event") != "event": + return 0 + if os.environ.get("PATH_MODE", "smooth") != "smooth": + return 1 + try: + duration_ms = float(os.environ.get("PATH_DURATION_MS", "2500")) + rate_hz = float(os.environ.get("PATH_RATE_HZ", "120")) + except ValueError: + return 1 + return max(1, int(duration_ms * rate_hz / 1000 * 0.5)) + + with open(log_path, "r", encoding="utf-8", errors="replace") as handle: lines = handle.readlines() @@ -132,6 +149,7 @@ def int_field(summary: dict, key: str) -> int: "live_chrome.active_layer_chrome_render_gap": threshold( "MAX_ACTIVE_LAYER_CHROME_RENDER_GAP_P95_MS", display_gap_budget_ms ), + "live_chrome.frame_tick_gap": threshold("MAX_FRAME_TICK_GAP_P95_MS", display_gap_budget_ms), "live_chrome.layer_render_duration": threshold("MAX_LAYER_RENDER_DURATION_P95_MS", None), "live_chrome.layer_chrome_render_duration": threshold( "MAX_LAYER_CHROME_RENDER_DURATION_P95_MS", target_budget_ms @@ -176,6 +194,7 @@ def int_field(summary: dict, key: str) -> int: predicted_moves = int_field(summary, "predictedMoves") fallback_refreshes = int_field(summary, "fallbackRefreshes") immediate_refreshes = int_field(summary, "immediateRefreshes") + min_mouse_events = expected_min_mouse_events() print( "[smoke] input summary " f"mouseEvents={mouse_events} followTicks={follow_ticks} " @@ -184,10 +203,14 @@ def int_field(summary: dict, key: str) -> int: f"loupeFastMoveSuccesses={loupe_fast_successes} " f"predictedMoves={predicted_moves} " f"fallbackRefreshes={fallback_refreshes} " - f"immediateRefreshes={immediate_refreshes}" + f"immediateRefreshes={immediate_refreshes} " + f"minMouseEvents={min_mouse_events}" ) - if mouse_events == 0: - failures.append("live HUD smoke did not deliver mouse movement events") + if mouse_events < min_mouse_events: + failures.append( + f"live HUD smoke delivered {mouse_events} mouse movement events, " + f"expected at least {min_mouse_events}" + ) for name in reported: if name not in metrics: diff --git a/scripts/smoke/native-hud-follow-macos.sh b/scripts/smoke/native-hud-follow-macos.sh index b1216a4c..5417a2dd 100755 --- a/scripts/smoke/native-hud-follow-macos.sh +++ b/scripts/smoke/native-hud-follow-macos.sh @@ -25,7 +25,9 @@ Useful overrides: 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_FRAME_TICK_GAP_P95_MS default: active display target budget + 1ms MAX_LAYER_CHROME_RENDER_DURATION_P95_MS default: active display target budget + MIN_MOUSE_EVENTS default: 50% of smooth event path target count EOF }