mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong path silently killed the ESTABLISHED-TCP activator (root cause of the failed live test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for High-Performance sessions, per-signal transition logging, unconditional error logging for dead probe helpers, and probe v2 with full CGSession dictionary diffing. 7 new parser tests (32 total). Fixes from a full audit + adversarial review: idle source setting honored (default now Remote session activity), cover scope reduced to a coherent two-mode model with legacy migration (per-display toggle was inverted in onlyMarked and dead in all), curtain test no longer schedules a teardown over a live session, specific-display password box placement gets a real picker, refuse-to-arm enforced, activation notification posts a real banner, menu password gate bypassed when the event tap is dead, shared single-decoder aerial player with stale-task guard and async playability check, password buffer zeroed on successful unlock and Esc, XPC interruption/invalidation handlers, modern Accessibility settings URL, launchPath modernized, codesign failures now abort release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead armDisarmHotkey setting removed. Refactor: Curtain.swift and PreferencesWindow.swift split into focused files (largest now 479 lines). Wiki, README, and contributing docs updated to match. Build clean at 0 warnings, 32/32 tests pass.
177 lines
8.8 KiB
Swift
177 lines
8.8 KiB
Swift
import Cocoa
|
|
|
|
/// Purpose: Block PHYSICAL keyboard/mouse from reaching apps while passing REMOTE
|
|
/// (Screen Sharing) input through, so the host desk is inert but the
|
|
/// remote operator controls normally.
|
|
/// How: a CGEventTap inspects each event's source-state. Physical hardware events
|
|
/// report sourceStateID == 1 (kCGEventSourceStateHIDSystemState); Screen
|
|
/// Sharing injects synthetic events with a per-session state ID (!= 1).
|
|
/// Block ==1 (and route physical key-downs to the password box), pass the rest.
|
|
/// Honesty: this is a CONVENIENCE filter, not a security boundary. Any local process
|
|
/// can post events with an arbitrary sourceStateID, so injected input can be made
|
|
/// to look "remote" and slip past. It keeps a person at the desk from interfering;
|
|
/// it does not stop hostile local code. Some HID paths may also bypass a session
|
|
/// tap entirely (documented residual).
|
|
/// Inputs: `onPhysicalKey(keycode, characters, flags)` fires on the main thread for
|
|
/// each physical key-down, where flags is the CGEvent modifier mask raw value.
|
|
/// `start()`/`stop()`/`ensureActive()`/retry helpers drive it.
|
|
/// Outputs: returns false from start() when the tap can't be created (no Accessibility);
|
|
/// `isTapInstalled` lets the coordinator surface that to the user.
|
|
/// Constraints: requires Accessibility permission. Tap callback runs on a dedicated tap
|
|
/// thread; all Cocoa work is hopped to main. Pinned to the main run loop.
|
|
/// SPORT: MASTER-INPUTFILTER
|
|
@MainActor
|
|
final class InputFilter {
|
|
private var tap: CFMachPort?
|
|
private var runLoopSource: CFRunLoopSource?
|
|
private var watchdog: Timer?
|
|
private var retryTimer: Timer?
|
|
|
|
/// Fired on the main thread for every physical key-down: (keycode, characters,
|
|
/// modifier-flags raw value). The flags let the controller match a reveal combo.
|
|
var onPhysicalKey: ((Int, String?, UInt64) -> Void)?
|
|
|
|
/// True once a tap is live. Coordinator shows an "Accessibility needed" warning
|
|
/// when this stays false after start().
|
|
var isTapInstalled: Bool { tap != nil }
|
|
|
|
// physicalStateID lives in the nonisolated extension below so the C-callback
|
|
// can reach it without an actor hop. See InputFilter extension at end of file.
|
|
|
|
/// Install the tap on the main run loop. Returns false if the tap could not be
|
|
/// created (missing Accessibility), so the caller can prompt or retry.
|
|
@discardableResult
|
|
func start() -> Bool {
|
|
assert(Thread.isMainThread, "InputFilter.start() must run on the main thread")
|
|
if tap != nil { return true }
|
|
|
|
let types: [CGEventType] = [.keyDown, .keyUp, .flagsChanged, .mouseMoved,
|
|
.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
|
|
.otherMouseDown, .otherMouseUp, .leftMouseDragged, .rightMouseDragged,
|
|
.scrollWheel]
|
|
// CGEventType has no .systemDefined case, but the underlying tap accepts its raw
|
|
// value (14 == NX_SYSDEFINED). Including it blocks physical media / brightness /
|
|
// volume / Mission-Control keys too. Some lower-level HID paths can still bypass a
|
|
// session tap; that residual is accepted.
|
|
let systemDefinedMask: CGEventMask = CGEventMask(1) << 14
|
|
let mask: CGEventMask = types.reduce(systemDefinedMask) { $0 | (CGEventMask(1) << $1.rawValue) }
|
|
let me = Unmanaged.passUnretained(self).toOpaque()
|
|
guard let t = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
|
|
options: .defaultTap, eventsOfInterest: mask, callback: callback, userInfo: me) else {
|
|
Log.event("event tap NOT installed (Accessibility?)")
|
|
return false
|
|
}
|
|
tap = t
|
|
let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, t, 0)
|
|
runLoopSource = src
|
|
// Pin to the main run loop so the tap fires no matter which thread called start().
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes)
|
|
CGEvent.tapEnable(tap: t, enable: true)
|
|
startWatchdog()
|
|
Log.event("event tap installed")
|
|
return true
|
|
}
|
|
|
|
func stop() {
|
|
cancelRetry()
|
|
watchdog?.invalidate(); watchdog = nil
|
|
if let t = tap { CGEvent.tapEnable(tap: t, enable: false) }
|
|
if let s = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), s, .commonModes) }
|
|
tap = nil; runLoopSource = nil
|
|
}
|
|
|
|
/// Re-enable the tap if the system disabled it (timeout / heavy input). Safe to call
|
|
/// repeatedly; backs the watchdog and any coordinator-driven retry.
|
|
func ensureActive() {
|
|
guard let t = tap else { return }
|
|
if !CGEvent.tapIsEnabled(tap: t) { CGEvent.tapEnable(tap: t, enable: true) }
|
|
}
|
|
|
|
/// While the tap is NOT installed, poll Accessibility trust ~every 2s and retry
|
|
/// start() once it flips true. `onSuccess` fires exactly once after install.
|
|
func retryUntilTrusted(onSuccess: @escaping () -> Void) {
|
|
if isTapInstalled { onSuccess(); return }
|
|
cancelRetry()
|
|
retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
|
|
Task { @MainActor in
|
|
guard let self else { timer.invalidate(); return }
|
|
if self.isTapInstalled { self.cancelRetry(); onSuccess(); return }
|
|
if AXIsProcessTrusted(), self.start() {
|
|
Log.event("event tap installed after Accessibility grant")
|
|
self.cancelRetry(); onSuccess()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop the trust-polling loop started by retryUntilTrusted.
|
|
func cancelRetry() {
|
|
retryTimer?.invalidate(); retryTimer = nil
|
|
}
|
|
|
|
// Nudge a disabled tap back to life without caller intervention.
|
|
private func startWatchdog() {
|
|
watchdog?.invalidate()
|
|
watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in self?.ensureActive() }
|
|
}
|
|
}
|
|
|
|
// Builds the Cocoa key string on the main thread, off the tap thread.
|
|
// Guard tap != nil: both this method and stop() are @MainActor, so the check
|
|
// is race-correct. If stop() ran before this async hop landed, discard the key
|
|
// rather than delivering it to an already-torn-down handler.
|
|
fileprivate func deliverPhysicalKey(keyCode: Int, characters: String?, flags: UInt64) {
|
|
guard tap != nil else { return }
|
|
onPhysicalKey?(keyCode, characters, flags)
|
|
}
|
|
|
|
fileprivate func reenableFromCallback() {
|
|
ensureActive()
|
|
}
|
|
}
|
|
|
|
// Top-level C callback. A CGEventTapCallBack is a bare C function pointer: it can't
|
|
// capture context, so the InputFilter is recovered from `refcon`. Only cheap field
|
|
// reads happen here; any Cocoa work is hopped to main.
|
|
private let callback: CGEventTapCallBack = { _, type, event, refcon in
|
|
guard let refcon else { return Unmanaged.passUnretained(event) }
|
|
let unmanaged = Unmanaged<InputFilter>.fromOpaque(refcon)
|
|
|
|
// The system disables the tap on timeout or under input load. Re-enable and let the
|
|
// notification event pass through untouched (do not return nil for these types).
|
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
DispatchQueue.main.async { unmanaged.takeUnretainedValue().reenableFromCallback() }
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
// Cheap read only. Physical hardware reports sourceStateID == 1.
|
|
let physical = event.getIntegerValueField(.eventSourceStateID) == InputFilter.physicalStateIDValue
|
|
guard physical else {
|
|
return Unmanaged.passUnretained(event) // remote / injected input: pass through
|
|
}
|
|
|
|
// Physical input: block it from apps. Route key-downs to the password box, resolving
|
|
// the unicode cheaply on the tap thread and hopping value types to main.
|
|
if type == .keyDown {
|
|
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
|
|
var length = 0
|
|
var buffer = [UniChar](repeating: 0, count: 4)
|
|
event.keyboardGetUnicodeString(maxStringLength: 4, actualStringLength: &length, unicodeString: &buffer)
|
|
let chars = length > 0 ? String(utf16CodeUnits: buffer, count: length) : nil
|
|
// Cheap modifier read on the tap thread; a plain UInt64 crosses to main safely.
|
|
let flags = event.flags.rawValue
|
|
DispatchQueue.main.async {
|
|
unmanaged.takeUnretainedValue().deliverPhysicalKey(keyCode: keyCode, characters: chars, flags: flags)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
extension InputFilter {
|
|
// Both constants live here (nonisolated context) so the C-callback can read them
|
|
// without crossing actor isolation. physicalStateID is the single source of truth;
|
|
// physicalStateIDValue is the name exposed at the call site in the callback.
|
|
fileprivate static let physicalStateID: Int64 = 1
|
|
fileprivate nonisolated static var physicalStateIDValue: Int64 { physicalStateID }
|
|
}
|