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.
127 lines
4.8 KiB
Swift
127 lines
4.8 KiB
Swift
import Cocoa
|
|
import Carbon.HIToolbox
|
|
import os.log
|
|
|
|
/// Purpose: A last-resort, always-available "take the cover down" hotkey that works
|
|
/// even when Accessibility is NOT granted. Wraps Carbon `RegisterEventHotKey`,
|
|
/// which (unlike a CGEventTap or NSEvent global monitor) needs no Accessibility
|
|
/// permission, so it remains the guaranteed escape if anything ever traps the
|
|
/// screen at the desk.
|
|
/// Inputs: register(_:) takes a () -> Void handler invoked on the main actor when the
|
|
/// fixed combo Control+Option+Command+U is pressed.
|
|
/// Outputs: none directly — it drives the stored handler.
|
|
/// Constraints: Carbon hotkey APIs are C. The event handler MUST be a top-level C
|
|
/// function (no Swift context capture), so the Swift handler is held in a
|
|
/// static registry keyed by hotkey id and the C callback hops to the main
|
|
/// actor via DispatchQueue.main.async before calling it. Single instance is
|
|
/// assumed; the static registry keys by signature+id to stay correct anyway.
|
|
/// SPORT: MASTER-EMERGENCYHOTKEY
|
|
@MainActor
|
|
final class EmergencyHotkey {
|
|
|
|
/// Fixed combo: Control+Option+Command+U. U keycode = 32 (kVK_ANSI_U).
|
|
private static let keyCode: UInt32 = 32
|
|
private static let modifiers: UInt32 = UInt32(controlKey | optionKey | cmdKey)
|
|
private static let signature: OSType = 0x4355_5254 // 'CURT'
|
|
private static let hotKeyID: UInt32 = 1
|
|
|
|
/// Bridge store: Carbon C callbacks can't capture Swift context, so the handler
|
|
/// lives here keyed by hotkey id and the C trampoline looks it up.
|
|
private static var handlers: [UInt32: () -> Void] = [:]
|
|
|
|
private var hotKeyRef: EventHotKeyRef?
|
|
private var eventHandlerRef: EventHandlerRef?
|
|
|
|
init() {}
|
|
|
|
/// Install the global hotkey and store the handler. Idempotent: a second call
|
|
/// unregisters the prior registration first.
|
|
func register(_ handler: @escaping () -> Void) {
|
|
unregister()
|
|
EmergencyHotkey.handlers[EmergencyHotkey.hotKeyID] = handler
|
|
|
|
var eventType = EventTypeSpec(
|
|
eventClass: OSType(kEventClassKeyboard),
|
|
eventKind: UInt32(kEventHotKeyPressed)
|
|
)
|
|
let status = InstallEventHandler(
|
|
GetApplicationEventTarget(),
|
|
emergencyHotkeyHandler,
|
|
1,
|
|
&eventType,
|
|
nil,
|
|
&eventHandlerRef
|
|
)
|
|
guard status == noErr else {
|
|
NSLog("Curtain: emergency hotkey handler install failed (\(status))")
|
|
return
|
|
}
|
|
|
|
let id = EventHotKeyID(signature: EmergencyHotkey.signature, id: EmergencyHotkey.hotKeyID)
|
|
let regStatus = RegisterEventHotKey(
|
|
EmergencyHotkey.keyCode,
|
|
EmergencyHotkey.modifiers,
|
|
id,
|
|
GetApplicationEventTarget(),
|
|
0,
|
|
&hotKeyRef
|
|
)
|
|
if regStatus == noErr {
|
|
os_log("Curtain: emergency hotkey registered (Control+Option+Command+U)")
|
|
} else {
|
|
NSLog("Curtain: emergency hotkey registration failed (\(regStatus))")
|
|
}
|
|
}
|
|
|
|
/// Tear down the hotkey and clear the stored handler.
|
|
func unregister() {
|
|
if let ref = hotKeyRef {
|
|
UnregisterEventHotKey(ref)
|
|
hotKeyRef = nil
|
|
}
|
|
if let ref = eventHandlerRef {
|
|
RemoveEventHandler(ref)
|
|
eventHandlerRef = nil
|
|
}
|
|
EmergencyHotkey.handlers[EmergencyHotkey.hotKeyID] = nil
|
|
}
|
|
|
|
deinit {
|
|
if let ref = hotKeyRef { UnregisterEventHotKey(ref) }
|
|
if let ref = eventHandlerRef { RemoveEventHandler(ref) }
|
|
}
|
|
|
|
/// Called by the C trampoline (in a nonisolated context) when the combo fires.
|
|
/// Hops to the main actor, looks up the stored handler by id, and runs it. The
|
|
/// `handlers` read happens inside the main-actor hop, so it's isolation-safe.
|
|
nonisolated fileprivate static func fire(id: UInt32) {
|
|
DispatchQueue.main.async {
|
|
MainActor.assumeIsolated {
|
|
handlers[id]?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level C event handler. Carbon hands us the hotkey id; we forward it to the
|
|
/// Swift bridge, which hops to the main actor. No Swift context is captured here.
|
|
private func emergencyHotkeyHandler(
|
|
_ nextHandler: EventHandlerCallRef?,
|
|
_ event: EventRef?,
|
|
_ userData: UnsafeMutableRawPointer?
|
|
) -> OSStatus {
|
|
guard let event else { return OSStatus(eventNotHandledErr) }
|
|
var hkID = EventHotKeyID()
|
|
let status = GetEventParameter(
|
|
event,
|
|
EventParamName(kEventParamDirectObject),
|
|
EventParamType(typeEventHotKeyID),
|
|
nil,
|
|
MemoryLayout<EventHotKeyID>.size,
|
|
nil,
|
|
&hkID
|
|
)
|
|
guard status == noErr else { return status }
|
|
EmergencyHotkey.fire(id: hkID.id)
|
|
return noErr
|
|
}
|