curtain/Sources/Curtain/SessionCoordinator.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
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.
2026-06-09 20:36:30 -04:00

290 lines
12 KiB
Swift

import Cocoa
import os.log
/// Purpose: The brain. Wires the session monitor, curtain, input filter, and the
/// configurable lifecycle actions together, and runs the connect / idle /
/// end / password flow as an explicit, idempotent state machine. Holds no
/// UI of its own it owns AppKit objects and publishes state changes.
/// Inputs: none at construction; behavior is driven entirely by Settings + the
/// monitor callbacks.
/// Outputs: onStateChange (curtain active?) and onArmedChange (armed?) for the menu.
/// Constraints: @MainActor it owns AppKit objects and the monitor callbacks
/// already hop to main. Every public transition is idempotent: a
/// double-activate, a deactivate-while-idle, or a reconnect-while-active
/// must never double-run lock/sleep or leave the tap dangling. The
/// password-unlock path disconnects the remote BEFORE revealing the
/// desktop, and never blocks the runloop with a modal (that would freeze
/// the event tap). When disarmed it stays passive and ignores the monitor.
/// SPORT: MASTER-COORDINATOR
@MainActor
final class SessionCoordinator {
/// Curtain active? Drives the menu-bar icon.
var onStateChange: ((Bool) -> Void)?
/// Armed? Drives the menu's arm/disarm item.
var onArmedChange: ((Bool) -> Void)?
let curtain = CurtainController()
let input = InputFilter()
private let monitor = SessionMonitor()
private lazy var runner = ActionRunner(curtain: curtain, input: input)
private var tickTimer: Timer?
/// Always-available escape hatch deactivates without Accessibility. See start().
private var emergencyHotkey: EmergencyHotkey?
/// Pending connect grace; canceled if the session drops before it elapses.
private var connectGrace: DispatchWorkItem?
/// Pending test-curtain teardown; canceled if a real session connects mid-test.
private var testTeardown: DispatchWorkItem?
/// Explicit lifecycle state. Every transition is guarded so it stays idempotent.
private enum State { case idle, active }
private var state: State = .idle
// MARK: - Setup
func start() {
input.onPhysicalKey = { [weak self] kc, chars, flags in self?.curtain.physicalKey(kc, chars, flags) }
curtain.onUnlock = { [weak self] in self?.handlePasswordUnlock() }
monitor.onConnect = { [weak self] in self?.sessionConnected() }
monitor.onIdleTimeout = { [weak self] in self?.sessionIdled() }
monitor.onDisconnect = { [weak self] in self?.sessionEnded() }
monitor.start()
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in self?.curtain.tick() }
}
// Reflect the persisted disconnect-helper setting into the System handler hook.
enableDisconnectHelper(Settings.disconnectFeatureEnabled)
// Always-on emergency escape. Carbon RegisterEventHotKey needs no Accessibility,
// so Control+Option+Command+U force-deactivates even if the tap never installed.
let hotkey = EmergencyHotkey()
hotkey.register { [weak self] in
Log.event("emergency hotkey: force deactivate")
self?.deactivateNow()
self?.postNotification(title: "Curtain", body: "Curtain deactivated (emergency hotkey).")
}
emergencyHotkey = hotkey
}
// MARK: - User notifications
/// Throttled (~60s) prompt shown when an activation is refused because Accessibility
/// isn't granted. Without the tap the cover can't be unlocked at the desk.
private func notifyAccessibilityNeeded() {
Notifier.post(
title: "Curtain",
body: "Grant Accessibility to Curtain to use the privacy cover. System Settings > Privacy & Security > Accessibility.",
throttleKey: "accessibility-needed",
throttleSeconds: 60
)
}
private func postNotification(title: String, body: String) {
Notifier.post(title: title, body: body)
}
// MARK: - Monitor events (gated by armed)
private func sessionConnected() {
guard Settings.armed else { return }
// A real session wins over any in-flight test: keep whatever is on screen.
testTeardown?.cancel(); testTeardown = nil
guard Settings.onStartActivate else { return }
// Grace window: a brief, flaky connection shouldn't flash the cover up.
connectGrace?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.connectGrace = nil
// Re-check armed: the user may have disarmed while the work item was
// already executing (past the point where cancel() could stop it).
guard Settings.armed else { return }
self?.enterActive(notify: true)
}
connectGrace = work
DispatchQueue.main.asyncAfter(deadline: .now() + Double(Settings.connectGraceSeconds), execute: work)
}
private func sessionIdled() {
guard Settings.armed, state == .active else { return }
Log.event("running idle actions")
applySet(Settings.onIdle)
}
private func sessionEnded() {
guard Settings.armed else { return }
// If the session never made it past grace, just drop the pending activate.
connectGrace?.cancel(); connectGrace = nil
guard state == .active else { return }
Log.event("running end actions")
applySet(Settings.onEnd)
}
// MARK: - State machine
/// Raise the cover and move to .active. No-op if already active.
///
/// `requireAX` gates indefinite activations (real session + "Activate Now") on
/// Accessibility: without the event tap the cover both fails to block desk input
/// AND can't be unlocked at the desk, so it must never be shown. The bounded
/// `testCurtain(seconds:)` path passes `requireAX: false` it auto-deactivates
/// after its timeout, so it's a safe visual test even without AX.
private func enterActive(notify: Bool, requireAX: Bool = true) {
guard state == .idle else { return }
Log.event("activate requested")
if requireAX, !AXIsProcessTrusted() {
Log.event("activation refused: Accessibility not granted")
NSLog("Curtain: refusing to cover — Accessibility not granted (would trap the desk)")
notifyAccessibilityNeeded()
return
}
state = .active
runner.activateCover()
Log.event("cover activated")
onStateChange?(true)
if notify { announceActivation() }
}
/// Take the cover down and move to .idle. No-op if already idle.
private func enterIdle() {
guard state == .active else { return }
state = .idle
runner.deactivateCover()
onStateChange?(false)
}
/// Run a configured set, then resync our state to whatever the cover ended at.
/// This keeps idle/end actions (which may or may not deactivate) idempotent: if
/// the set tears the cover down we land in .idle; if it leaves it up we stay
/// .active so a later disconnect re-arms cleanly without double-running actions.
private func applySet(_ set: ActionSet) {
runner.run(set)
state = curtain.isShown ? .active : .idle
onStateChange?(curtain.isShown)
}
// MARK: - Password unlock (desk reveal)
/// Host typed the correct password at the desk. Order is load-bearing: if the
/// remote should be cut, sever it FIRST so the operator never sees the desktop,
/// and only then drop the cover. No modal that would freeze the runloop the
/// event tap rides on.
private func handlePasswordUnlock() {
Log.event("password accepted; unlockDisconnect=\(Settings.unlockDisconnect)")
if Settings.unlockDisconnect {
System.endScreenShareSession()
}
enterIdle()
}
// MARK: - Manual controls
func activateNow() {
connectGrace?.cancel(); connectGrace = nil
enterActive(notify: false)
}
/// Force the cover down with no password gate. Used internally and on quit.
func deactivateNow() {
connectGrace?.cancel(); connectGrace = nil
testTeardown?.cancel(); testTeardown = nil
enterIdle()
}
/// Menu-driven deactivate. If the setting requires a password and the cover is
/// up, refuse to deactivate, surface the on-curtain password box, and report
/// false. Otherwise deactivate and report true.
@discardableResult
func requestDeactivateFromMenu() -> Bool {
if Settings.requirePasswordToDeactivateFromMenu, state == .active, input.isTapInstalled {
// Keep the cover up: the tap is live, so a physical keypress raises the
// on-curtain password box that is the intended unlock path. If the tap
// is NOT installed (transient tap-create failure after the AX check), the
// box can never receive keys, so refusing the menu here would strand the
// desk with only the emergency hotkey; fall through and deactivate instead.
return false
}
deactivateNow()
return true
}
var isActive: Bool { state == .active }
var isArmed: Bool { Settings.armed }
/// Persist the armed flag. Disarming forces the cover down immediately so the
/// Mac is never left covered by a system the user just turned off. When the
/// user chose "Refuse to arm" for missing Accessibility, arming is rejected
/// outright (with a notification) rather than arming a system that could only
/// warn at connect time the cover would refuse to rise anyway.
func setArmed(_ on: Bool) {
if on, Settings.accessibilityRefuseToArm, !AXIsProcessTrusted() {
Log.event("arming refused: Accessibility not granted (refuseToArm)")
Notifier.post(title: "Curtain",
body: "Not armed: grant Accessibility first (Settings > Security).")
onArmedChange?(false)
return
}
Log.event("armed=\(on)")
Settings.armed = on
onArmedChange?(on)
if !on { deactivateNow() }
}
/// Briefly show the cover for a visual check. Cancelable, and a real connect
/// during the window cancels the teardown so we don't tear down a live session.
func testCurtain(seconds: TimeInterval) {
// Refuse to schedule a bounded test teardown while a REAL session has the
// cover up that would drop a live session's cover after the test delay.
guard state == .idle else { return }
testTeardown?.cancel()
// Bounded + auto-deactivating, so it's safe to show even without Accessibility.
enterActive(notify: false, requireAX: false)
let work = DispatchWorkItem { [weak self] in
self?.testTeardown = nil
self?.enterIdle()
}
testTeardown = work
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
}
/// Persist the disconnect-helper toggle and reconcile the privileged daemon.
/// Registering/unregistering the LaunchDaemon and (re)installing the disconnect
/// handler is delegated to DisconnectClient, which is idempotent and logs errors.
func enableDisconnectHelper(_ on: Bool) {
// Settings exposes this flag read-only; persist via the shared defaults key.
UserDefaults.standard.set(on, forKey: Settings.Key.disconnectFeatureEnabled)
DisconnectClient.shared.setEnabled(on)
DisconnectClient.shared.syncWithSettings()
}
/// Cleanup path for app termination: drop the cover and release the assertion.
/// runner.deactivateCover() already releases the IOKit display-sleep assertion,
/// so a second direct call to System.allowDisplaySleep() here is redundant and
/// was removed to keep the release path in exactly one place.
func deactivateNowForQuit() {
connectGrace?.cancel(); connectGrace = nil
testTeardown?.cancel(); testTeardown = nil
runner.deactivateCover()
state = .idle
}
// MARK: - Activation feedback
private func announceActivation() {
if Settings.notifyOnActivate {
os_log("Curtain active: desk covered, physical input blocked")
// Surface a real banner via UNUserNotificationCenter so the user
// (or a test harness) can observe the activation event in Notification
// Center, not just the system log.
Notifier.post(title: "Curtain", body: "Privacy curtain activated.")
}
if Settings.playSoundOnActivate {
NSSound.beep()
}
}
}