Skip to content
Open
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 @@ -631,6 +631,12 @@ private struct TBDisplaySenderSessionSettingsSheet: View {
}
}

if session.inputControlRole != .off {
SurfaceSubcard {
TBInputBindingsView(session: session)
}
}

if session.inputControlRole == .senderMaster, !service.localInputMonitoringTrusted {
SurfaceSubcard {
permissionWarningCard(
Expand Down
21 changes: 17 additions & 4 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,11 @@ final class TBDisplaySenderService: ObservableObject {

private static let persistedSessionsKey = "fd.tbdisplaysender.sessions.v1"

/// Snapshot of the user-configurable settings for a single session. Runtime
/// state (connection, input master role, FPS, …) is intentionally excluded —
/// only the choices the user makes in the UI are remembered across launches.
/// Snapshot of the user-configurable settings for a single session. Transient
/// runtime state (connection, FPS, …) is intentionally excluded — only the
/// choices the user makes in the UI are remembered across launches.
/// `inputBindings` is optional for backward compatibility with sessions saved
/// before it was added.
private struct PersistedSession: Codable {
var transportKind: String
var localInterfaceIP: String
Expand All @@ -251,6 +253,7 @@ final class TBDisplaySenderService: ObservableObject {
var brightness: Double
var inputGestureMode: String
var volume: Double?
var inputBindings: [TBInputBinding]?
}

private var lastPersistedData: Data?
Expand Down Expand Up @@ -282,7 +285,8 @@ final class TBDisplaySenderService: ObservableObject {
audioEnabled: session.audioEnabled,
brightness: session.brightness,
inputGestureMode: session.inputGestureMode.rawValue,
volume: session.volume
volume: session.volume,
inputBindings: session.inputBindings
)
}
guard let data = try? JSONEncoder().encode(configs) else { return }
Expand Down Expand Up @@ -336,6 +340,9 @@ final class TBDisplaySenderService: ObservableObject {
if let gesture = TBInputGestureMode(rawValue: config.inputGestureMode) {
session.inputGestureMode = gesture
}
if let bindings = config.inputBindings {
session.inputBindings = bindings
}
session.receiverIP = config.receiverIP
session.selectedReceiverID = config.selectedReceiverID
session.localInterfaceIP = config.localInterfaceIP
Expand Down Expand Up @@ -550,6 +557,12 @@ final class TBDisplaySenderService: ObservableObject {
deactivateHandler: { [weak self, weak session] in
guard let self, let session else { return }
self.setInputControlRole(.off, for: session)
},
triggerMatcher: { [weak session] keyCode, modifiers in
guard let session,
let binding = TBInputBindingEngine.match(keyCode: keyCode, modifiers: modifiers, in: session.inputBindings)
else { return nil }
return TBInputBindingEngine.injectionSequence(heldModifiers: modifiers, action: binding.action)
}
)
}
Expand Down
95 changes: 93 additions & 2 deletions TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
}
}
@Published var inputGestureMode: TBInputGestureMode = .native
/// User-defined shortcut bindings for this session (trigger on master →
/// action injected on slave). See TBInputBinding.
@Published var inputBindings: [TBInputBinding] = []

private var connection: NWConnection?
private let connectionQueue = DispatchQueue(label: "fd.tbmonitor.sender.connection", qos: .userInteractive)
Expand Down Expand Up @@ -999,6 +1002,9 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
private var injectedOptionDown = false
private var injectedControlDown = false
private var injectedCapsDown = false
/// While a binding trigger key is held (matched), swallow its key-up so the
/// raw trigger key never reaches the slave.
private var suppressedTriggerKeyCode: UInt16?
private static var cachedSupportsHEVCHardwareEncode: Bool?
private var receivedInputEventCount: UInt64 = 0
var onRemoteSwitchRequest: ((Int) -> Void)?
Expand Down Expand Up @@ -1847,14 +1853,99 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u
case "scroll":
postLocalScroll(scrollX: event.scrollX ?? 0, scrollY: event.scrollY ?? 0)
case "keyDown":
if let keyCode = event.keyCode { postLocalKey(keyCode: keyCode, isDown: true) }
if let keyCode = event.keyCode {
if handleIncomingTriggerKeyDown(keyCode) { return }
postLocalKey(keyCode: keyCode, isDown: true)
}
case "keyUp":
if let keyCode = event.keyCode { postLocalKey(keyCode: keyCode, isDown: false) }
if let keyCode = event.keyCode {
if keyCode == suppressedTriggerKeyCode {
suppressedTriggerKeyCode = nil
return
}
postLocalKey(keyCode: keyCode, isDown: false)
}
default:
break
}
}

/// receiverMaster: if the incoming key-down completes a binding trigger,
/// inject the action locally and swallow the trigger. Returns true if handled.
private func handleIncomingTriggerKeyDown(_ keyCode: UInt16) -> Bool {
guard !TBInputBindingEngine.isModifierKeyCode(keyCode), !inputBindings.isEmpty else { return false }
// Debounce key-repeat: ignore repeats while the trigger is still held.
if keyCode == suppressedTriggerKeyCode { return true }
let held = currentHeldModifierBits()
guard let binding = TBInputBindingEngine.match(keyCode: keyCode, modifiers: held, in: inputBindings) else {
return false
}
suppressedTriggerKeyCode = keyCode
TBInputDebugLog.log("binding MATCH: trigger=\(binding.trigger.displayString) -> inject \(binding.action.displayString)")
injectActionViaSystemEvents(binding.action)
return true
}

/// Inject a binding action through System Events (AppleScript) rather than a
/// raw CGEvent. The WindowServer ignores synthetic CGEvent presses for
/// protected symbolic hotkeys (e.g. ⌃← to switch Spaces), but honors the same
/// shortcut when it comes from the trusted System Events process.
///
/// The user may be holding the trigger's modifiers, which we inject as held
/// CGEvent state — that would contaminate the action (e.g. a stray ⌥). So we
/// release the held modifiers first so System Events sees a clean combo, then
/// restore them once osascript finishes.
private func injectActionViaSystemEvents(_ action: TBInputShortcut) {
let heldKeyCodes = currentlyHeldModifierKeyCodes()
for keyCode in heldKeyCodes { postLocalKey(keyCode: keyCode, isDown: false) }

// Run the AppleScript in-process (NSAppleScript), NOT via /usr/bin/osascript:
// when spawned, osascript is the keystroke-sending client and lacks
// Accessibility (error 1002). In-process, this app is the client and it
// already holds Accessibility + Automation, so System Events is allowed
// to post the shortcut.
let source = "tell application \"System Events\" to key code \(action.keyCode)\(Self.appleScriptModifierClause(action.modifiers))"
DispatchQueue.global(qos: .userInitiated).async {
var errorInfo: NSDictionary?
NSAppleScript(source: source)?.executeAndReturnError(&errorInfo)
let failure: String? = errorInfo.map { "\($0)" }
Task { @MainActor [weak self] in
guard let self else { return }
if let failure { TBInputDebugLog.log("system-events inject error: \(failure)") }
for keyCode in heldKeyCodes { self.postLocalKey(keyCode: keyCode, isDown: true) }
}
}
}

private func currentlyHeldModifierKeyCodes() -> [UInt16] {
var codes: [UInt16] = []
if injectedControlDown { codes.append(59) }
if injectedOptionDown { codes.append(58) }
if injectedShiftDown { codes.append(56) }
if injectedCommandDown { codes.append(55) }
return codes
}

private static func appleScriptModifierClause(_ modifiers: UInt32) -> String {
var parts: [String] = []
if modifiers & TBInputShortcut.control != 0 { parts.append("control down") }
if modifiers & TBInputShortcut.option != 0 { parts.append("option down") }
if modifiers & TBInputShortcut.shift != 0 { parts.append("shift down") }
if modifiers & TBInputShortcut.command != 0 { parts.append("command down") }
guard !parts.isEmpty else { return "" }
return " using {" + parts.joined(separator: ", ") + "}"
}

/// Current held modifier state (our bitmask) reconstructed from injected keys.
private func currentHeldModifierBits() -> UInt32 {
var m: UInt32 = 0
if injectedControlDown { m |= TBInputShortcut.control }
if injectedOptionDown { m |= TBInputShortcut.option }
if injectedShiftDown { m |= TBInputShortcut.shift }
if injectedCommandDown { m |= TBInputShortcut.command }
return m
}

private func handleDisplayProfile(_ payload: Data) {
guard activeProfile == nil,
let profile = TBMonitorProtocol.decodeJSON(TBMonitorDisplayProfile.self, from: payload)
Expand Down
118 changes: 118 additions & 0 deletions TargetBridge-Sender/TBDisplaySender/TBInputBinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import AppKit
import CoreGraphics
import Foundation

/// A keyboard shortcut: a key plus a stable modifier bitmask.
///
/// Modifiers use our own bits (independent of AppKit/CoreGraphics) so they
/// persist reliably: control = 1, option = 2, shift = 4, command = 8.
struct TBInputShortcut: Codable, Equatable, Hashable {
var keyCode: UInt16
var modifiers: UInt32

static let control: UInt32 = 1 << 0
static let option: UInt32 = 1 << 1
static let shift: UInt32 = 1 << 2
static let command: UInt32 = 1 << 3

/// (bit, left-hand modifier key code), in a stable nesting order.
static let modifierTable: [(bit: UInt32, keyCode: UInt16)] = [
(control, 59),
(option, 58),
(shift, 56),
(command, 55)
]

var hasModifiers: Bool { modifiers != 0 }

static func modifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
var m: UInt32 = 0
if flags.contains(.control) { m |= control }
if flags.contains(.option) { m |= option }
if flags.contains(.shift) { m |= shift }
if flags.contains(.command) { m |= command }
return m
}

/// Display string such as `⌃⌥←` or `⌘⇧A`.
var displayString: String {
var s = ""
if modifiers & Self.control != 0 { s += "⌃" }
if modifiers & Self.option != 0 { s += "⌥" }
if modifiers & Self.shift != 0 { s += "⇧" }
if modifiers & Self.command != 0 { s += "⌘" }
s += TBKeyCodeNames.name(for: keyCode)
return s
}
}

/// A per-session binding: pressing `trigger` on the master injects `action` on
/// the slave (direction follows the session's input master role).
struct TBInputBinding: Codable, Identifiable, Equatable {
var id: UUID = UUID()
var trigger: TBInputShortcut
var action: TBInputShortcut
var enabled: Bool = true
}

enum TBInputBindingEngine {
/// Build the self-contained key sequence that injects `action` while the
/// master currently holds `heldModifiers`. Returns `(keyCode, isDown)` steps.
///
/// It temporarily releases held modifiers the action doesn't use, presses the
/// action's missing modifiers, taps the action key, then restores the held
/// state — so the slave sees exactly the action's modifiers (e.g. trigger
/// `⌃⌥←` can inject a clean `⌃←`), and modifier tracking ends where it began.
static func injectionSequence(heldModifiers: UInt32, action: TBInputShortcut) -> [(keyCode: UInt16, isDown: Bool)] {
let toRelease = TBInputShortcut.modifierTable.filter {
heldModifiers & $0.bit != 0 && action.modifiers & $0.bit == 0
}
let toPress = TBInputShortcut.modifierTable.filter {
action.modifiers & $0.bit != 0 && heldModifiers & $0.bit == 0
}

var seq: [(keyCode: UInt16, isDown: Bool)] = []
for mod in toRelease.reversed() { seq.append((mod.keyCode, false)) }
for mod in toPress { seq.append((mod.keyCode, true)) }
seq.append((action.keyCode, true))
seq.append((action.keyCode, false))
for mod in toPress.reversed() { seq.append((mod.keyCode, false)) }
for mod in toRelease { seq.append((mod.keyCode, true)) }
return seq
}

static func isModifierKeyCode(_ keyCode: UInt16) -> Bool {
switch keyCode {
case 54, 55, 56, 57, 58, 59, 60, 61, 62: return true
default: return false
}
}

/// Find the enabled binding whose trigger matches `keyCode` + exact `modifiers`.
static func match(keyCode: UInt16, modifiers: UInt32, in bindings: [TBInputBinding]) -> TBInputBinding? {
bindings.first { $0.enabled && $0.trigger.keyCode == keyCode && $0.trigger.modifiers == modifiers }
}
}

/// Human-readable names for common virtual key codes (for shortcut display).
enum TBKeyCodeNames {
private static let table: [UInt16: String] = [
123: "←", 124: "→", 125: "↓", 126: "↑",
36: "↩", 48: "⇥", 49: "Space", 51: "⌫", 53: "⎋", 117: "⌦",
115: "↖", 119: "↘", 116: "⇞", 121: "⇟",
122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6",
98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12",
0: "A", 11: "B", 8: "C", 2: "D", 14: "E", 3: "F", 5: "G", 4: "H",
34: "I", 38: "J", 40: "K", 37: "L", 46: "M", 45: "N", 31: "O", 35: "P",
12: "Q", 15: "R", 1: "S", 17: "T", 32: "U", 9: "V", 13: "W", 7: "X",
16: "Y", 6: "Z",
29: "0", 18: "1", 19: "2", 20: "3", 21: "4", 23: "5", 22: "6",
26: "7", 28: "8", 25: "9",
27: "-", 24: "=", 33: "[", 30: "]", 41: ";", 39: "'", 43: ",",
47: ".", 44: "/", 42: "\\", 50: "`"
]

static func name(for keyCode: UInt16) -> String {
table[keyCode] ?? "key\(keyCode)"
}
}
Loading