Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ final class ReceiverBackedVirtualDisplaySession {
activatePreferredMode(for: display.displayID,
mode: resolvedMode,
refreshRate: preferredRefreshRate,
hiDPI: profile.hiDPI,
savedChoice: savedChoice)

virtualDisplay = display
Expand Down Expand Up @@ -183,13 +184,14 @@ final class ReceiverBackedVirtualDisplaySession {
private func activatePreferredMode(for displayID: CGDirectDisplayID,
mode: TBVirtualDisplayModeSize,
refreshRate: Double,
hiDPI: Bool,
savedChoice: TBVirtualDisplayModeMemory.Choice?) -> Bool {
let timeout = Date().addingTimeInterval(2.0)
while Date() < timeout {
var success = false
autoreleasepool {
let chosenMode = savedChoice.flatMap { savedMode(for: displayID, choice: $0) }
?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate)
?? preferredMode(for: displayID, mode: mode, refreshRate: refreshRate, hiDPI: hiDPI)
if let chosenMode {
success = CGDisplaySetDisplayMode(displayID, chosenMode, nil) == .success
}
Expand Down Expand Up @@ -223,8 +225,16 @@ final class ReceiverBackedVirtualDisplaySession {
return candidates.first
}

private func preferredMode(for displayID: CGDirectDisplayID, mode: TBVirtualDisplayModeSize, refreshRate: Double) -> CGDisplayMode? {
guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, nil) else {
/// Select the mode by its backing pixels as well as its logical size. A
/// virtual HiDPI display exposes both a Retina 2x and a low-resolution 1x
/// variant that can report the same logical 2560x1440 size. Choosing only
/// by points made Render Matching restore the blurry 1x variant.
private func preferredMode(for displayID: CGDirectDisplayID,
mode: TBVirtualDisplayModeSize,
refreshRate: Double,
hiDPI: Bool) -> CGDisplayMode? {
let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary
guard let modesCF = CGDisplayCopyAllDisplayModes(displayID, options) else {
return nil
}
let modes = modesCF as? [CGDisplayMode] ?? []
Expand All @@ -233,10 +243,16 @@ final class ReceiverBackedVirtualDisplaySession {
candidate.width == mode.width && candidate.height == mode.height
}.sorted { $0.refreshRate > $1.refreshRate }

if let exactMatch = matchingModes.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) {
let expectedPixelWidth = hiDPI ? mode.backingWidth : mode.width
let expectedPixelHeight = hiDPI ? mode.backingHeight : mode.height
let backingMatches = matchingModes.filter { candidate in
candidate.pixelWidth == expectedPixelWidth && candidate.pixelHeight == expectedPixelHeight
}

if let exactMatch = backingMatches.first(where: { abs($0.refreshRate - refreshRate) < 0.5 }) {
return exactMatch
}

return matchingModes.first
return backingMatches.first ?? matchingModes.first
}
}
18 changes: 15 additions & 3 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
private var cursorDisplayID: CGDirectDisplayID = kCGNullDirectDisplay
private var lastCursorPacket: TBMonitorCursor?
private var injectedRemoteMouseLocation: CGPoint?
private var cachedLocalInputEventSource: CGEventSource?
private var injectedLeftClickTracker = TBInjectedClickStateTracker()
private let localPointerModifierBridge = TBLocalPointerModifierBridge()
private var injectedCommandDown = false
Expand Down Expand Up @@ -2117,14 +2118,21 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
}

private func localInputEventSource() -> CGEventSource? {
if let cachedLocalInputEventSource {
return cachedLocalInputEventSource
}
let source = CGEventSource(stateID: .hidSystemState)
source?.localEventsSuppressionInterval = 0
cachedLocalInputEventSource = source
return source
}

private func logLocalInputInjectionStateIfNeeded(context: String) {
let trusted = AXIsProcessTrusted()
TBInputDebugLog.log("sender input injection state trusted=\(trusted) context=\(context)")
if context == "mouseMove" {
TBInputDebugLog.logInputEvent("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)")
} else {
TBInputDebugLog.log("sender input injection state trusted=\(AXIsProcessTrusted()) context=\(context)")
}
}

private func postLocalMouseMove(dx: Int, dy: Int, type: CGEventType = .mouseMoved, button: CGMouseButton = .left) {
Expand Down Expand Up @@ -2277,7 +2285,11 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
}

private func applyIncomingInputEvent(_ event: TBMonitorInputEvent, payload: Data) {
TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)")
if event.kind == "move" || event.kind.hasSuffix("Drag") || event.kind == "scroll" {
TBInputDebugLog.logInputEvent("sender applying incoming event kind=\(event.kind)")
} else {
TBInputDebugLog.log("sender applying incoming event kind=\(event.kind)")
}
switch event.kind {
case "move":
postLocalMouseMove(dx: event.dx ?? 0, dy: event.dy ?? 0)
Expand Down
68 changes: 50 additions & 18 deletions TargetBridge-Sender/TBDisplayShared/TBInputDebugLog.swift
Original file line number Diff line number Diff line change
@@ -1,37 +1,69 @@
import Foundation

@MainActor
enum TBInputDebugLog {
private static let fileManager = FileManager.default
private final class TBAsyncInputDebugLogWriter: @unchecked Sendable {
private let fileManager = FileManager.default
private let writeQueue = DispatchQueue(label: "fd.tbdisplaysender.input-debug-log", qos: .utility)

private static var logURL: URL {
func enqueue(_ message: String) {
writeQueue.async { [self] in
write(message)
}
}

private func write(_ message: String) {
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true)
return base
let url = base
.appendingPathComponent("TargetBridge", isDirectory: true)
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("input-debug.log", isDirectory: false)
}

static func log(_ message: String) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "\(timestamp) \(message)\n"
let url = logURL
let directory = url.deletingLastPathComponent()
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)

if let data = line.data(using: .utf8) {
if fileManager.fileExists(atPath: url.path) {
if let handle = try? FileHandle(forWritingTo: url) {
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
}
} else {
try? data.write(to: url, options: .atomic)
guard let data = line.data(using: .utf8) else { return }
if fileManager.fileExists(atPath: url.path) {
if let handle = try? FileHandle(forWritingTo: url) {
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
}
} else {
try? data.write(to: url, options: .atomic)
}
}
}

@MainActor
enum TBInputDebugLog {
private static let writer = TBAsyncInputDebugLogWriter()
private static var inputEventCount: UInt64 = 0
private static let inputEventBurstLimit: UInt64 = 20
private static let inputEventSampleInterval: UInt64 = 100

private static var logURL: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true)
return base
.appendingPathComponent("TargetBridge", isDirectory: true)
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("input-debug.log", isDirectory: false)
}

static func log(_ message: String) {
writer.enqueue(message)
}

/// Mouse motion can arrive hundreds of times per second. Keep the first few
/// records plus a periodic sample, without putting file I/O on the input path.
static func logInputEvent(_ message: @autoclosure () -> String) {
inputEventCount &+= 1
let count = inputEventCount

guard count <= inputEventBurstLimit || count.isMultiple(of: inputEventSampleInterval) else { return }
log(message())
}

static var currentLogPath: String {
logURL.path
Expand Down
6 changes: 6 additions & 0 deletions TargetBridge-Sender/TargetBridgeSupport/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
<string>TargetBridge requires screen recording permission to capture the virtual display or mirror the desktop to the iMac.</string>
<key>NSAppleEventsUsageDescription</key>
<string>TargetBridge uses System Events to run shortcuts selected for Receiver is Master.</string>
<key>NSBonjourServices</key>
<array>
<string>_targetbridge._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>TargetBridge connects to your receiver over Thunderbolt Bridge or your local network.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
Expand Down
Loading